]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Instance homepage support (#4007)
authorChocobozzz <chocobozzz@cpy.re>
Thu, 27 May 2021 13:59:55 +0000 (15:59 +0200)
committerGitHub <noreply@github.com>
Thu, 27 May 2021 13:59:55 +0000 (15:59 +0200)
* Prepare homepage parsers

* Add ability to update instance hompage

* Add ability to set homepage as landing page

* Add homepage preview in admin

* Dynamically update left menu for homepage

* Inject home content in homepage

* Add videos list and channel miniature custom markup

* Remove unused elements in markup service

84 files changed:
client/src/app/+about/about-peertube/about-peertube-contributors.component.ts
client/src/app/+admin/admin.module.ts
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html [new file with mode: 0644]
client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts [new file with mode: 0644]
client/src/app/+admin/config/edit-custom-config/index.ts
client/src/app/+home/home-routing.module.ts [new file with mode: 0644]
client/src/app/+home/home.component.html [new file with mode: 0644]
client/src/app/+home/home.component.scss [new file with mode: 0644]
client/src/app/+home/home.component.ts [new file with mode: 0644]
client/src/app/+home/home.module.ts [new file with mode: 0644]
client/src/app/+home/index.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment.component.ts
client/src/app/+videos/+video-watch/video-watch.component.ts
client/src/app/app-routing.module.ts
client/src/app/app.component.ts
client/src/app/core/menu/menu.service.ts
client/src/app/core/renderer/html-renderer.service.ts
client/src/app/core/renderer/markdown.service.ts
client/src/app/core/server/server.service.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.ts
client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/custom-markup.service.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/dynamic-element.service.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/embed-markup.component.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/index.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/videos-list-markup.component.html [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss [new file with mode: 0644]
client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/markdown-textarea.component.html
client/src/app/shared/shared-forms/markdown-textarea.component.ts
client/src/app/shared/shared-icons/global-icon.component.ts
client/src/app/shared/shared-main/custom-page/custom-page.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/custom-page/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/shared-main.module.ts
client/src/assets/images/feather/octagon.svg [new file with mode: 0644]
client/src/assets/player/utils.ts
server.ts
server/controllers/api/config.ts
server/controllers/api/custom-page.ts [new file with mode: 0644]
server/controllers/api/index.ts
server/controllers/api/videos/import.ts
server/controllers/static.ts
server/helpers/markdown.ts
server/initializers/constants.ts
server/initializers/database.ts
server/initializers/migrations/0650-actor-custom-pages.ts [new file with mode: 0644]
server/lib/client-html.ts
server/lib/config.ts [deleted file]
server/lib/job-queue/handlers/video-import.ts
server/lib/plugins/plugin-helpers-builder.ts
server/lib/server-config-manager.ts [new file with mode: 0644]
server/models/account/actor-custom-page.ts [new file with mode: 0644]
server/tests/api/check-params/custom-pages.ts [new file with mode: 0644]
server/tests/api/check-params/index.ts
server/tests/api/server/homepage.ts [new file with mode: 0644]
server/tests/api/server/index.ts
server/types/models/account/actor-custom-page.ts [new file with mode: 0644]
server/types/models/account/index.ts
shared/core-utils/miscs/miscs.ts
shared/core-utils/renderer/html.ts
shared/extra-utils/custom-pages/custom-pages.ts [new file with mode: 0644]
shared/extra-utils/index.ts
shared/models/actors/custom-page.model.ts [new file with mode: 0644]
shared/models/actors/index.ts
shared/models/custom-markup/custom-markup-data.model.ts [new file with mode: 0644]
shared/models/custom-markup/index.ts [new file with mode: 0644]
shared/models/index.ts
shared/models/server/server-config.model.ts
shared/models/users/user-right.enum.ts
support/doc/api/openapi.yaml

index c45269be48601dd5e83bbaf8f2990081aba7027c..dd774a4ef902d4d56d66dfdf39e971b8dcb21873 100644 (file)
@@ -14,6 +14,6 @@ export class AboutPeertubeContributorsComponent implements OnInit {
   constructor (private markdownService: MarkdownService) { }
 
   async ngOnInit () {
-    this.creditsHtml = await this.markdownService.completeMarkdownToHTML(this.markdown)
+    this.creditsHtml = await this.markdownService.unsafeMarkdownToHTML(this.markdown, true)
   }
 }
index 45366f9ec41c1770d2a22548a250c61bc3530b74..a7fe20b074cb12c015ab843ac10cbed465bce987 100644 (file)
@@ -4,12 +4,13 @@ import { TableModule } from 'primeng/table'
 import { NgModule } from '@angular/core'
 import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
 import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit'
+import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module'
+import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
 import { SharedFormModule } from '@app/shared/shared-forms'
 import { SharedGlobalIconModule } from '@app/shared/shared-icons'
 import { SharedMainModule } from '@app/shared/shared-main'
 import { SharedModerationModule } from '@app/shared/shared-moderation'
 import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
-import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
 import { AdminRoutingModule } from './admin-routing.module'
 import { AdminComponent } from './admin.component'
 import {
@@ -18,6 +19,7 @@ import {
   EditBasicConfigurationComponent,
   EditConfigurationService,
   EditCustomConfigComponent,
+  EditHomepageComponent,
   EditInstanceInformationComponent,
   EditLiveConfigurationComponent,
   EditVODTranscodingComponent
@@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
     SharedVideoCommentModule,
     SharedActorImageModule,
     SharedActorImageEditModule,
+    SharedCustomMarkupModule,
 
     TableModule,
     SelectButtonModule,
@@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
     EditVODTranscodingComponent,
     EditLiveConfigurationComponent,
     EditAdvancedConfigurationComponent,
-    EditInstanceInformationComponent
+    EditInstanceInformationComponent,
+    EditHomepageComponent
   ],
 
   exports: [
index 84a793ae4ba387cf1c6ba4866408440466e8e5a2..451e6a34ae8cf415cea5bed6910fa971cc08c8fd 100644 (file)
       <div class="form-group" formGroupName="instance">
         <label i18n for="instanceDefaultClientRoute">Landing page</label>
 
-        <div class="peertube-select-container">
-          <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control">
-            <option i18n value="/videos/overview">Discover videos</option>
-
-            <optgroup i18n-label label="Trending pages">
-              <option i18n value="/videos/trending">Default trending page</option>
-              <option i18n value="/videos/trending?alg=best" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('best')">Best videos</option>
-              <option i18n value="/videos/trending?alg=hot" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('hot')">Hot videos</option>
-              <option i18n value="/videos/trending?alg=most-viewed" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-viewed')">Most viewed videos</option>
-              <option i18n value="/videos/trending?alg=most-liked" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-liked')">Most liked videos</option>
-            </optgroup>
-
-            <option i18n value="/videos/recently-added">Recently added videos</option>
-            <option i18n value="/videos/local">Local videos</option>
-          </select>
-        </div>
+        <my-select-custom-value
+          id="instanceDefaultClientRoute"
+          [items]="defaultLandingPageOptions"
+          formControlName="defaultClientRoute"
+          inputType="text"
+          [clearable]="false"
+        ></my-select-custom-value>
 
         <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
       </div>
index 34d05f9f3446b60ac6e2d81f5aae55491361d575..d50148e7aba674589878727f998fbe1da5b46b0b 100644 (file)
@@ -1,7 +1,9 @@
 
 import { pairwise } from 'rxjs/operators'
-import { Component, Input, OnInit } from '@angular/core'
+import { SelectOptionsItem } from 'src/types/select-options-item.model'
+import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
 import { FormGroup } from '@angular/forms'
+import { MenuService } from '@app/core'
 import { ServerConfig } from '@shared/models'
 import { ConfigService } from '../shared/config.service'
 
@@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service'
   templateUrl: './edit-basic-configuration.component.html',
   styleUrls: [ './edit-custom-config.component.scss' ]
 })
-export class EditBasicConfigurationComponent implements OnInit {
+export class EditBasicConfigurationComponent implements OnInit, OnChanges {
   @Input() form: FormGroup
   @Input() formErrors: any
 
   @Input() serverConfig: ServerConfig
 
   signupAlertMessage: string
+  defaultLandingPageOptions: SelectOptionsItem[] = []
 
   constructor (
-    private configService: ConfigService
+    private configService: ConfigService,
+    private menuService: MenuService
   ) { }
 
   ngOnInit () {
+    this.buildLandingPageOptions()
     this.checkSignupField()
   }
 
+  ngOnChanges (changes: SimpleChanges) {
+    if (changes['serverConfig']) {
+      this.buildLandingPageOptions()
+    }
+  }
+
   getVideoQuotaOptions () {
     return this.configService.videoQuotaOptions
   }
@@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit {
     return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
   }
 
+  buildLandingPageOptions () {
+    this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig)
+      .map(o => ({
+        id: o.path,
+        label: o.label,
+        description: o.path
+      }))
+  }
+
   private checkSignupField () {
     const signupControl = this.form.get('signup.enabled')
 
index b6365614dcda896cd9996d7c87c1cd0f269089ae..3ceea02ca7a0f825e9458d111253e641db4e4cd1 100644 (file)
@@ -3,8 +3,16 @@
 
   <div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
 
+    <ng-container ngbNavItem="instance-homepage">
+      <a ngbNavLink i18n>Homepage</a>
+
+      <ng-template ngbNavContent>
+        <my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
+      </ng-template>
+    </ng-container>
+
     <ng-container ngbNavItem="instance-information">
-      <a ngbNavLink i18n>Instance information</a>
+      <a ngbNavLink i18n>Information</a>
 
       <ng-template ngbNavContent>
         <my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
@@ -13,7 +21,7 @@
     </ng-container>
 
     <ng-container ngbNavItem="basic-configuration">
-      <a ngbNavLink i18n>Basic configuration</a>
+      <a ngbNavLink i18n>Basic</a>
 
       <ng-template ngbNavContent>
         <my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
@@ -40,7 +48,7 @@
     </ng-container>
 
     <ng-container ngbNavItem="advanced-configuration">
-      <a ngbNavLink i18n>Advanced configuration</a>
+      <a ngbNavLink i18n>Advanced</a>
 
       <ng-template ngbNavContent>
         <my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
index 4b35d65fca8292b8c6b78cd678430caa1b0b9437..dc8334dd0f06108a676867bb73b5bb611de38544 100644 (file)
@@ -1,4 +1,5 @@
 
+import omit from 'lodash-es/omit'
 import { forkJoin } from 'rxjs'
 import { SelectOptionsItem } from 'src/types/select-options-item.model'
 import { Component, OnInit } from '@angular/core'
@@ -24,9 +25,14 @@ import {
 } from '@app/shared/form-validators/custom-config-validators'
 import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
 import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { CustomConfig, ServerConfig } from '@shared/models'
+import { CustomPageService } from '@app/shared/shared-main/custom-page'
+import { CustomConfig, CustomPage, ServerConfig } from '@shared/models'
 import { EditConfigurationService } from './edit-configuration.service'
 
+type ComponentCustomConfig = CustomConfig & {
+  instanceCustomHomepage: CustomPage
+}
+
 @Component({
   selector: 'my-edit-custom-config',
   templateUrl: './edit-custom-config.component.html',
@@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service'
 export class EditCustomConfigComponent extends FormReactive implements OnInit {
   activeNav: string
 
-  customConfig: CustomConfig
+  customConfig: ComponentCustomConfig
   serverConfig: ServerConfig
 
+  homepage: CustomPage
+
   languageItems: SelectOptionsItem[] = []
   categoryItems: SelectOptionsItem[] = []
 
@@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
     protected formValidatorService: FormValidatorService,
     private notifier: Notifier,
     private configService: ConfigService,
+    private customPage: CustomPageService,
     private serverService: ServerService,
     private editConfigurationService: EditConfigurationService
   ) {
@@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
   ngOnInit () {
     this.serverConfig = this.serverService.getTmpConfig()
     this.serverService.getConfig()
-        .subscribe(config => {
-          this.serverConfig = config
-        })
+        .subscribe(config => this.serverConfig = config)
 
-    const formGroupData: { [key in keyof CustomConfig ]: any } = {
+    const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
       instance: {
         name: INSTANCE_NAME_VALIDATOR,
         shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
@@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
           disableLocalSearch: null,
           isDefaultSearch: null
         }
+      },
+
+      instanceCustomHomepage: {
+        content: null
       }
     }
 
@@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
   }
 
   async formValidated () {
-    const value: CustomConfig = this.form.getRawValue()
+    const value: ComponentCustomConfig = this.form.getRawValue()
 
-    this.configService.updateCustomConfig(value)
+    forkJoin([
+      this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
+      this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
+    ])
       .subscribe(
-        res => {
-          this.customConfig = res
+        ([ resConfig ]) => {
+          const instanceCustomHomepage = {
+            content: value.instanceCustomHomepage.content
+          }
+
+          this.customConfig = { ...resConfig, instanceCustomHomepage }
 
           // Reload general configuration
           this.serverService.resetConfig()
+            .subscribe(config => this.serverConfig = config)
 
           this.updateForm()
 
@@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
   }
 
   private loadConfigAndUpdateForm () {
-    this.configService.getCustomConfig()
-      .subscribe(config => {
-        this.customConfig = config
+    forkJoin([
+      this.configService.getCustomConfig(),
+      this.customPage.getInstanceHomepage()
+    ])
+      .subscribe(([ config, homepage ]) => {
+        this.customConfig = { ...config, instanceCustomHomepage: homepage }
 
         this.updateForm()
         // Force form validation
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
new file mode 100644 (file)
index 0000000..c48fa5b
--- /dev/null
@@ -0,0 +1,28 @@
+<ng-container [formGroup]="form">
+
+  <ng-container formGroupName="instanceCustomHomepage">
+
+    <div class="form-row mt-5"> <!-- homepage grid -->
+      <div class="form-group col-12 col-lg-4 col-xl-3">
+        <div i18n class="inner-form-title">INSTANCE HOMEPAGE</div>
+      </div>
+
+      <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
+
+        <div class="form-group">
+          <label i18n for="instanceCustomHomepageContent">Homepage</label>
+
+          <my-markdown-textarea
+            name="instanceCustomHomepageContent" formControlName="content" textareaMaxWidth="90%" textareaHeight="300px"
+            [customMarkdownRenderer]="customMarkdownRenderer"
+            [classes]="{ 'input-error': formErrors['instanceCustomHomepage.content'] }"
+          ></my-markdown-textarea>
+
+          <div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error">{{ formErrors.instanceCustomHomepage.content }}</div>
+        </div>
+      </div>
+    </div>
+
+  </ng-container>
+
+</ng-container>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts
new file mode 100644 (file)
index 0000000..7decf8f
--- /dev/null
@@ -0,0 +1,25 @@
+import { Component, Input, OnInit } from '@angular/core'
+import { FormGroup } from '@angular/forms'
+import { CustomMarkupService } from '@app/shared/shared-custom-markup'
+
+@Component({
+  selector: 'my-edit-homepage',
+  templateUrl: './edit-homepage.component.html',
+  styleUrls: [ './edit-custom-config.component.scss' ]
+})
+export class EditHomepageComponent implements OnInit {
+  @Input() form: FormGroup
+  @Input() formErrors: any
+
+  customMarkdownRenderer: (text: string) => Promise<HTMLElement>
+
+  constructor (private customMarkup: CustomMarkupService) {
+
+  }
+
+  ngOnInit () {
+    this.customMarkdownRenderer = async (text: string) => {
+      return this.customMarkup.buildElement(text)
+    }
+  }
+}
index 95fcc8f52677fb097c826604f97fdb1be1303bb2..4281ad09b7b2f5b547b8ec461e73cc6f8c979ebc 100644 (file)
@@ -2,6 +2,7 @@ export * from './edit-advanced-configuration.component'
 export * from './edit-basic-configuration.component'
 export * from './edit-configuration.service'
 export * from './edit-custom-config.component'
+export * from './edit-homepage.component'
 export * from './edit-instance-information.component'
 export * from './edit-live-configuration.component'
 export * from './edit-vod-transcoding.component'
diff --git a/client/src/app/+home/home-routing.module.ts b/client/src/app/+home/home-routing.module.ts
new file mode 100644 (file)
index 0000000..1eaee44
--- /dev/null
@@ -0,0 +1,18 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MetaGuard } from '@ngx-meta/core'
+import { HomeComponent } from './home.component'
+
+const homeRoutes: Routes = [
+  {
+    path: '',
+    component: HomeComponent,
+    canActivateChild: [ MetaGuard ]
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(homeRoutes) ],
+  exports: [ RouterModule ]
+})
+export class HomeRoutingModule {}
diff --git a/client/src/app/+home/home.component.html b/client/src/app/+home/home.component.html
new file mode 100644 (file)
index 0000000..645b9dc
--- /dev/null
@@ -0,0 +1,4 @@
+<div class="root margin-content">
+  <div #contentWrapper></div>
+</div>
+
diff --git a/client/src/app/+home/home.component.scss b/client/src/app/+home/home.component.scss
new file mode 100644 (file)
index 0000000..6c73e92
--- /dev/null
@@ -0,0 +1,3 @@
+.root {
+  padding-top: 20px;
+}
diff --git a/client/src/app/+home/home.component.ts b/client/src/app/+home/home.component.ts
new file mode 100644 (file)
index 0000000..16d3a6d
--- /dev/null
@@ -0,0 +1,26 @@
+
+import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
+import { CustomMarkupService } from '@app/shared/shared-custom-markup'
+import { CustomPageService } from '@app/shared/shared-main/custom-page'
+
+@Component({
+  templateUrl: './home.component.html',
+  styleUrls: [ './home.component.scss' ]
+})
+
+export class HomeComponent implements OnInit {
+  @ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement>
+
+  constructor (
+    private customMarkupService: CustomMarkupService,
+    private customPageService: CustomPageService
+  ) { }
+
+  async ngOnInit () {
+    this.customPageService.getInstanceHomepage()
+      .subscribe(async ({ content }) => {
+        const element = await this.customMarkupService.buildElement(content)
+        this.contentWrapper.nativeElement.appendChild(element)
+      })
+  }
+}
diff --git a/client/src/app/+home/home.module.ts b/client/src/app/+home/home.module.ts
new file mode 100644 (file)
index 0000000..102cdc2
--- /dev/null
@@ -0,0 +1,25 @@
+import { NgModule } from '@angular/core'
+import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { HomeRoutingModule } from './home-routing.module'
+import { HomeComponent } from './home.component'
+
+@NgModule({
+  imports: [
+    HomeRoutingModule,
+
+    SharedMainModule,
+    SharedCustomMarkupModule
+  ],
+
+  declarations: [
+    HomeComponent
+  ],
+
+  exports: [
+    HomeComponent
+  ],
+
+  providers: [ ]
+})
+export class HomeModule { }
diff --git a/client/src/app/+home/index.ts b/client/src/app/+home/index.ts
new file mode 100644 (file)
index 0000000..7c77cf9
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './home-routing.module'
+export * from './home.component'
+export * from './home.module'
index fd379e80e7523e6c9e34de2614e27a88fdb2984a..04f8f0d5882c0e5a30f07d4a647af6b9330c25e6 100644 (file)
@@ -161,7 +161,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
     // Before HTML rendering restore line feed for markdown list compatibility
     const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
     const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
-    this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
+    this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html)
     this.newParentComments = this.parentComments.concat([ this.comment ])
 
     if (this.comment.account) {
index 116139d473e372fa39db69415c3914904b069904..77405d1499909d605bb47e8ec012b664e312eb03 100644 (file)
@@ -509,7 +509,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
   private async setVideoDescriptionHTML () {
     const html = await this.markdownService.textMarkdownToHTML(this.video.description)
-    this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
+    this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html)
   }
 
   private setVideoLikesBarTooltipText () {
index 3ea5b7e5e253e7de9a1b26771e94a132b0fbd125..57e485e8e63746f5c61b573cf527664200c0bbd4 100644 (file)
@@ -13,6 +13,10 @@ const routes: Routes = [
     canDeactivate: [ MenuGuards.open() ],
     loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
   },
+  {
+    path: 'home',
+    loadChildren: () => import('./+home/home.module').then(m => m.HomeModule)
+  },
   {
     path: 'my-account',
     loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule)
index 239e275a4ab70a453d64c57900e622863ecc97c3..863c3f3b5885242ca44d440d23f18086236e0771 100644 (file)
@@ -231,7 +231,7 @@ export class AppComponent implements OnInit, AfterViewInit {
         }
 
         this.broadcastMessage = {
-          message: await this.markdownService.completeMarkdownToHTML(messageConfig.message),
+          message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true),
           dismissable: messageConfig.dismissable,
           class: classes[messageConfig.level]
         }
index 502d3bb2fa538eb1c373e3e4c8ad3dcda678185b..77592cbb60cc9bf9aeaa64b6afb39841596373e4 100644 (file)
@@ -1,8 +1,19 @@
 import { fromEvent } from 'rxjs'
 import { debounceTime } from 'rxjs/operators'
 import { Injectable } from '@angular/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+import { sortObjectComparator } from '@shared/core-utils/miscs/miscs'
+import { ServerConfig } from '@shared/models/server'
 import { ScreenService } from '../wrappers'
 
+export type MenuLink = {
+  icon: GlobalIconName
+  label: string
+  menuLabel: string
+  path: string
+  priority: number
+}
+
 @Injectable()
 export class MenuService {
   isMenuDisplayed = true
@@ -48,6 +59,53 @@ export class MenuService {
     this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
   }
 
+  buildCommonLinks (config: ServerConfig) {
+    let entries: MenuLink[] = [
+      {
+        icon: 'globe' as 'globe',
+        label: $localize`Discover videos`,
+        menuLabel: $localize`Discover`,
+        path: '/videos/overview',
+        priority: 150
+      },
+      {
+        icon: 'trending' as 'trending',
+        label: $localize`Trending videos`,
+        menuLabel: $localize`Trending`,
+        path: '/videos/trending',
+        priority: 140
+      },
+      {
+        icon: 'recently-added' as 'recently-added',
+        label: $localize`Recently added videos`,
+        menuLabel: $localize`Recently added`,
+        path: '/videos/recently-added',
+        priority: 130
+      },
+      {
+        icon: 'octagon' as 'octagon',
+        label: $localize`Local videos`,
+        menuLabel: $localize`Local videos`,
+        path: '/videos/local',
+        priority: 120
+      }
+    ]
+
+    if (config.homepage.enabled) {
+      entries.push({
+        icon: 'home' as 'home',
+        label: $localize`Home`,
+        menuLabel: $localize`Home`,
+        path: '/home',
+        priority: 160
+      })
+    }
+
+    entries = entries.sort(sortObjectComparator('priority', 'desc'))
+
+    return entries
+  }
+
   private handleWindowResize () {
     // On touch screens, do not handle window resize event since opened menu is handled with a content overlay
     if (this.screenService.isInTouchScreen()) return
index 3176cf6a4c21795179a9234161712a168cb8a43a..418d8603eef35710200126dc162add313dbf61b4 100644 (file)
@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core'
 import { LinkifierService } from './linkifier.service'
-import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html'
+import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html'
 
 @Injectable()
 export class HtmlRendererService {
@@ -20,7 +20,7 @@ export class HtmlRendererService {
     })
   }
 
-  async toSafeHtml (text: string) {
+  async toSafeHtml (text: string, additionalAllowedTags: string[] = []) {
     const [ html ] = await Promise.all([
       // Convert possible markdown to html
       this.linkifier.linkify(text),
@@ -28,7 +28,11 @@ export class HtmlRendererService {
       this.loadSanitizeHtml()
     ])
 
-    return this.sanitizeHtml(html, SANITIZE_OPTIONS)
+    const options = additionalAllowedTags.length !== 0
+      ? getCustomMarkupSanitizeOptions(additionalAllowedTags)
+      : getSanitizeOptions()
+
+    return this.sanitizeHtml(html, options)
   }
 
   private async loadSanitizeHtml () {
index edddb0a6621056e00bd88b70f04298d04344ee5a..ca1bf4eb95416515bfeb1ebaec3e6191d3cbb5b2 100644 (file)
@@ -17,12 +17,15 @@ type MarkdownParsers = {
   enhancedMarkdownIt: MarkdownIt
   enhancedWithHTMLMarkdownIt: MarkdownIt
 
-  completeMarkdownIt: MarkdownIt
+  unsafeMarkdownIt: MarkdownIt
+
+  customPageMarkdownIt: MarkdownIt
 }
 
 type MarkdownConfig = {
   rules: string[]
   html: boolean
+  breaks: boolean
   escape?: boolean
 }
 
@@ -35,18 +38,24 @@ export class MarkdownService {
   private markdownParsers: MarkdownParsers = {
     textMarkdownIt: null,
     textWithHTMLMarkdownIt: null,
+
     enhancedMarkdownIt: null,
     enhancedWithHTMLMarkdownIt: null,
-    completeMarkdownIt: null
+
+    unsafeMarkdownIt: null,
+
+    customPageMarkdownIt: null
   }
   private parsersConfig: MarkdownParserConfigs = {
-    textMarkdownIt: { rules: TEXT_RULES, html: false },
-    textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true },
+    textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false },
+    textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true },
 
-    enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false },
-    enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true },
+    enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false },
+    enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, breaks: true, html: true, escape: true },
 
-    completeMarkdownIt: { rules: COMPLETE_RULES, html: true }
+    unsafeMarkdownIt: { rules: COMPLETE_RULES, breaks: true, html: true, escape: false },
+
+    customPageMarkdownIt: { rules: COMPLETE_RULES, breaks: false, html: true, escape: true }
   }
 
   private emojiModule: any
@@ -54,22 +63,26 @@ export class MarkdownService {
   constructor (private htmlRenderer: HtmlRendererService) {}
 
   textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
-    if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown, withEmoji)
+    if (withHtml) return this.render({ name: 'textWithHTMLMarkdownIt', markdown, withEmoji })
 
-    return this.render('textMarkdownIt', markdown, withEmoji)
+    return this.render({ name: 'textMarkdownIt', markdown, withEmoji })
   }
 
   enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
-    if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown, withEmoji)
+    if (withHtml) return this.render({ name: 'enhancedWithHTMLMarkdownIt', markdown, withEmoji })
+
+    return this.render({ name: 'enhancedMarkdownIt', markdown, withEmoji })
+  }
 
-    return this.render('enhancedMarkdownIt', markdown, withEmoji)
+  unsafeMarkdownToHTML (markdown: string, _trustedInput: true) {
+    return this.render({ name: 'unsafeMarkdownIt', markdown, withEmoji: true })
   }
 
-  completeMarkdownToHTML (markdown: string) {
-    return this.render('completeMarkdownIt', markdown, true)
+  customPageMarkdownToHTML (markdown: string, additionalAllowedTags: string[]) {
+    return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
   }
 
-  async processVideoTimestamps (html: string) {
+  processVideoTimestamps (html: string) {
     return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
       const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
       const url = buildVideoLink({ startTime: t })
@@ -77,7 +90,13 @@ export class MarkdownService {
     })
   }
 
-  private async render (name: keyof MarkdownParsers, markdown: string, withEmoji = false) {
+  private async render (options: {
+    name: keyof MarkdownParsers
+    markdown: string
+    withEmoji: boolean
+    additionalAllowedTags?: string[]
+  }) {
+    const { name, markdown, withEmoji, additionalAllowedTags } = options
     if (!markdown) return ''
 
     const config = this.parsersConfig[ name ]
@@ -96,7 +115,7 @@ export class MarkdownService {
     let html = this.markdownParsers[ name ].render(markdown)
     html = this.avoidTruncatedTags(html)
 
-    if (config.escape) return this.htmlRenderer.toSafeHtml(html)
+    if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags)
 
     return html
   }
@@ -105,7 +124,7 @@ export class MarkdownService {
     // FIXME: import('...') returns a struct module, containing a "default" field
     const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
 
-    const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
+    const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: config.breaks, html: config.html })
 
     for (const rule of config.rules) {
       markdownIt.enable(rule)
index e48786e185f5e055ce306314d53f6db51f9970bb..5b1b7603fdc61fa7aff43a1db0f97360d35fd352 100644 (file)
@@ -173,6 +173,9 @@ export class ServerService {
         disableLocalSearch: false,
         isDefaultSearch: false
       }
+    },
+    homepage: {
+      enabled: false
     }
   }
 
@@ -198,9 +201,7 @@ export class ServerService {
     this.configReset = true
 
     // Notify config update
-    this.getConfig().subscribe(() => {
-      // empty, to fire a reset config event
-    })
+    return this.getConfig()
   }
 
   getConfig () {
index 2e07deca2a4cfc50ced9fafa5353277b6673a9a3..fcc0bc21a891b772cafc2ec96fb2c186755697a4 100644 (file)
       <div class="on-instance">
         <div i18n class="block-title">ON {{instanceName}}</div>
 
-        <a class="menu-link" routerLink="/videos/overview" routerLinkActive="active">
-          <my-global-icon iconName="globe" aria-hidden="true"></my-global-icon>
-          <ng-container i18n>Discover</ng-container>
-        </a>
-
-        <a class="menu-link" routerLink="/videos/trending" routerLinkActive="active">
-          <my-global-icon iconName="trending" aria-hidden="true"></my-global-icon>
-          <ng-container i18n>Trending</ng-container>
-        </a>
-
-        <a class="menu-link" routerLink="/videos/recently-added" routerLinkActive="active">
-          <my-global-icon iconName="recently-added" aria-hidden="true"></my-global-icon>
-          <ng-container i18n>Recently added</ng-container>
-        </a>
-
-        <a class="menu-link" routerLink="/videos/local" routerLinkActive="active">
-          <my-global-icon iconName="home" aria-hidden="true"></my-global-icon>
-          <ng-container i18n>Local videos</ng-container>
+        <a class="menu-link" *ngFor="let commonLink of commonMenuLinks" [routerLink]="commonLink.path" routerLinkActive="active">
+          <my-global-icon [iconName]="commonLink.icon" aria-hidden="true"></my-global-icon>
+          <ng-container>{{ commonLink.menuLabel }}</ng-container>
         </a>
       </div>
     </div>
index 8fa1de3264baeeabbf26c89a4b5b50a8feb9db20..2f7e0cf07bea2699929d169934e03fdfc62b2d25 100644 (file)
@@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators'
 import { ViewportScroller } from '@angular/common'
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
-import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
+import {
+  AuthService,
+  AuthStatus,
+  AuthUser,
+  MenuLink,
+  MenuService,
+  RedirectService,
+  ScreenService,
+  ServerService,
+  UserService
+} from '@app/core'
 import { scrollToTop } from '@app/helpers'
 import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
 import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
@@ -35,6 +45,8 @@ export class MenuComponent implements OnInit {
 
   currentInterfaceLanguage: string
 
+  commonMenuLinks: MenuLink[] = []
+
   private languages: VideoConstant<string>[] = []
   private serverConfig: ServerConfig
   private routesPerRight: { [role in UserRight]?: string } = {
@@ -80,7 +92,10 @@ export class MenuComponent implements OnInit {
   ngOnInit () {
     this.serverConfig = this.serverService.getTmpConfig()
     this.serverService.getConfig()
-      .subscribe(config => this.serverConfig = config)
+      .subscribe(config => {
+        this.serverConfig = config
+        this.buildMenuLinks()
+      })
 
     this.isLoggedIn = this.authService.isLoggedIn()
     if (this.isLoggedIn === true) {
@@ -241,6 +256,10 @@ export class MenuComponent implements OnInit {
     }
   }
 
+  private buildMenuLinks () {
+    this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig)
+  }
+
   private buildUserLanguages () {
     if (!this.user) {
       this.videoLanguages = []
diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html
new file mode 100644 (file)
index 0000000..da81006
--- /dev/null
@@ -0,0 +1,8 @@
+<div *ngIf="channel" class="channel">
+  <my-actor-avatar [channel]="channel" size="34"></my-actor-avatar>
+
+  <div class="display-name">{{ channel.displayName }}</div>
+  <div class="username">{{ channel.name }}</div>
+
+  <div class="description">{{ channel.description }}</div>
+</div>
diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss
new file mode 100644 (file)
index 0000000..85018af
--- /dev/null
@@ -0,0 +1,9 @@
+@import '_variables';
+@import '_mixins';
+
+.channel {
+  border-radius: 15px;
+  padding: 10px;
+  width: min-content;
+  border: 1px solid pvar(--mainColor);
+}
diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts
new file mode 100644 (file)
index 0000000..97bb556
--- /dev/null
@@ -0,0 +1,26 @@
+import { Component, Input, OnInit } from '@angular/core'
+import { VideoChannel, VideoChannelService } from '../shared-main'
+
+/*
+ * Markup component that creates a channel miniature only
+*/
+
+@Component({
+  selector: 'my-channel-miniature-markup',
+  templateUrl: 'channel-miniature-markup.component.html',
+  styleUrls: [ 'channel-miniature-markup.component.scss' ]
+})
+export class ChannelMiniatureMarkupComponent implements OnInit {
+  @Input() name: string
+
+  channel: VideoChannel
+
+  constructor (
+    private channelService: VideoChannelService
+  ) { }
+
+  ngOnInit () {
+    this.channelService.getVideoChannel(this.name)
+      .subscribe(channel => this.channel = channel)
+  }
+}
diff --git a/client/src/app/shared/shared-custom-markup/custom-markup.service.ts b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts
new file mode 100644 (file)
index 0000000..ffaf157
--- /dev/null
@@ -0,0 +1,136 @@
+import { ComponentRef, Injectable } from '@angular/core'
+import { MarkdownService } from '@app/core'
+import {
+  ChannelMiniatureMarkupData,
+  EmbedMarkupData,
+  PlaylistMiniatureMarkupData,
+  VideoMiniatureMarkupData,
+  VideosListMarkupData
+} from '@shared/models'
+import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
+import { DynamicElementService } from './dynamic-element.service'
+import { EmbedMarkupComponent } from './embed-markup.component'
+import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
+import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
+import { VideosListMarkupComponent } from './videos-list-markup.component'
+
+type BuilderFunction = (el: HTMLElement) => ComponentRef<any>
+
+@Injectable()
+export class CustomMarkupService {
+  private builders: { [ selector: string ]: BuilderFunction } = {
+    'peertube-video-embed': el => this.embedBuilder(el, 'video'),
+    'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'),
+    'peertube-video-miniature': el => this.videoMiniatureBuilder(el),
+    'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el),
+    'peertube-channel-miniature': el => this.channelMiniatureBuilder(el),
+    'peertube-videos-list': el => this.videosListBuilder(el)
+  }
+
+  constructor (
+    private dynamicElementService: DynamicElementService,
+    private markdown: MarkdownService
+  ) { }
+
+  async buildElement (text: string) {
+    const html = await this.markdown.customPageMarkdownToHTML(text, this.getSupportedTags())
+
+    const rootElement = document.createElement('div')
+    rootElement.innerHTML = html
+
+    for (const selector of this.getSupportedTags()) {
+      rootElement.querySelectorAll(selector)
+        .forEach((e: HTMLElement) => {
+          try {
+            const component = this.execBuilder(selector, e)
+
+            this.dynamicElementService.injectElement(e, component)
+          } catch (err) {
+            console.error('Cannot inject component %s.', selector, err)
+          }
+        })
+    }
+
+    return rootElement
+  }
+
+  private getSupportedTags () {
+    return Object.keys(this.builders)
+  }
+
+  private execBuilder (selector: string, el: HTMLElement) {
+    return this.builders[selector](el)
+  }
+
+  private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') {
+    const data = el.dataset as EmbedMarkupData
+    const component = this.dynamicElementService.createElement(EmbedMarkupComponent)
+
+    this.dynamicElementService.setModel(component, { uuid: data.uuid, type })
+
+    return component
+  }
+
+  private videoMiniatureBuilder (el: HTMLElement) {
+    const data = el.dataset as VideoMiniatureMarkupData
+    const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent)
+
+    this.dynamicElementService.setModel(component, { uuid: data.uuid })
+
+    return component
+  }
+
+  private playlistMiniatureBuilder (el: HTMLElement) {
+    const data = el.dataset as PlaylistMiniatureMarkupData
+    const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent)
+
+    this.dynamicElementService.setModel(component, { uuid: data.uuid })
+
+    return component
+  }
+
+  private channelMiniatureBuilder (el: HTMLElement) {
+    const data = el.dataset as ChannelMiniatureMarkupData
+    const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent)
+
+    this.dynamicElementService.setModel(component, { name: data.name })
+
+    return component
+  }
+
+  private videosListBuilder (el: HTMLElement) {
+    const data = el.dataset as VideosListMarkupData
+    const component = this.dynamicElementService.createElement(VideosListMarkupComponent)
+
+    const model = {
+      title: data.title,
+      description: data.description,
+      sort: data.sort,
+      categoryOneOf: this.buildArrayNumber(data.categoryOneOf),
+      languageOneOf: this.buildArrayString(data.languageOneOf),
+      count: this.buildNumber(data.count) || 10
+    }
+
+    this.dynamicElementService.setModel(component, model)
+
+    return component
+  }
+
+  private buildNumber (value: string) {
+    if (!value) return undefined
+
+    return parseInt(value, 10)
+  }
+
+  private buildArrayNumber (value: string) {
+    if (!value) return undefined
+
+    return value.split(',').map(v => parseInt(v, 10))
+  }
+
+  private buildArrayString (value: string) {
+    if (!value) return undefined
+
+    return value.split(',')
+  }
+}
diff --git a/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts b/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts
new file mode 100644 (file)
index 0000000..e967e30
--- /dev/null
@@ -0,0 +1,57 @@
+import {
+  ApplicationRef,
+  ComponentFactoryResolver,
+  ComponentRef,
+  EmbeddedViewRef,
+  Injectable,
+  Injector,
+  OnChanges,
+  SimpleChange,
+  SimpleChanges,
+  Type
+} from '@angular/core'
+
+@Injectable()
+export class DynamicElementService {
+
+  constructor (
+    private injector: Injector,
+    private applicationRef: ApplicationRef,
+    private componentFactoryResolver: ComponentFactoryResolver
+  ) { }
+
+  createElement <T> (ofComponent: Type<T>) {
+    const div = document.createElement('div')
+
+    const component = this.componentFactoryResolver.resolveComponentFactory(ofComponent)
+      .create(this.injector, [], div)
+
+    return component
+  }
+
+  injectElement <T> (wrapper: HTMLElement, componentRef: ComponentRef<T>) {
+    const hostView = componentRef.hostView as EmbeddedViewRef<any>
+
+    this.applicationRef.attachView(hostView)
+    wrapper.appendChild(hostView.rootNodes[0])
+  }
+
+  setModel <T> (componentRef: ComponentRef<T>, attributes: Partial<T>) {
+    const changes: SimpleChanges = {}
+
+    for (const key of Object.keys(attributes)) {
+      const previousValue = componentRef.instance[key]
+      const newValue = attributes[key]
+
+      componentRef.instance[key] = newValue
+      changes[key] = new SimpleChange(previousValue, newValue, previousValue === undefined)
+    }
+
+    const component = componentRef.instance
+    if (typeof (component as unknown as OnChanges).ngOnChanges === 'function') {
+      (component as unknown as OnChanges).ngOnChanges(changes)
+    }
+
+    componentRef.changeDetectorRef.detectChanges()
+  }
+}
diff --git a/client/src/app/shared/shared-custom-markup/embed-markup.component.ts b/client/src/app/shared/shared-custom-markup/embed-markup.component.ts
new file mode 100644 (file)
index 0000000..a854d89
--- /dev/null
@@ -0,0 +1,22 @@
+import { buildPlaylistLink, buildVideoLink, buildVideoOrPlaylistEmbed } from 'src/assets/player/utils'
+import { environment } from 'src/environments/environment'
+import { Component, ElementRef, Input, OnInit } from '@angular/core'
+
+@Component({
+  selector: 'my-embed-markup',
+  template: ''
+})
+export class EmbedMarkupComponent implements OnInit {
+  @Input() uuid: string
+  @Input() type: 'video' | 'playlist' = 'video'
+
+  constructor (private el: ElementRef) { }
+
+  ngOnInit () {
+    const link = this.type === 'video'
+      ? buildVideoLink({ baseUrl: `${environment.originServerUrl}/videos/embed/${this.uuid}` })
+      : buildPlaylistLink({ baseUrl: `${environment.originServerUrl}/video-playlists/embed/${this.uuid}` })
+
+    this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed(link, this.uuid)
+  }
+}
diff --git a/client/src/app/shared/shared-custom-markup/index.ts b/client/src/app/shared/shared-custom-markup/index.ts
new file mode 100644 (file)
index 0000000..14bde3e
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './custom-markup.service'
+export * from './dynamic-element.service'
+export * from './shared-custom-markup.module'
diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html
new file mode 100644 (file)
index 0000000..4e1d1a1
--- /dev/null
@@ -0,0 +1,2 @@
+<my-video-playlist-miniature *ngIf="playlist" [playlist]="playlist">
+</my-video-playlist-miniature>
diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss
new file mode 100644 (file)
index 0000000..281cef7
--- /dev/null
@@ -0,0 +1,7 @@
+@import '_variables';
+@import '_mixins';
+
+my-video-playlist-miniature {
+  display: inline-block;
+  width: $video-thumbnail-width;
+}
diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts
new file mode 100644 (file)
index 0000000..7aee450
--- /dev/null
@@ -0,0 +1,38 @@
+import { Component, Input, OnInit } from '@angular/core'
+import { MiniatureDisplayOptions } from '../shared-video-miniature'
+import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist'
+
+/*
+ * Markup component that creates a playlist miniature only
+*/
+
+@Component({
+  selector: 'my-playlist-miniature-markup',
+  templateUrl: 'playlist-miniature-markup.component.html',
+  styleUrls: [ 'playlist-miniature-markup.component.scss' ]
+})
+export class PlaylistMiniatureMarkupComponent implements OnInit {
+  @Input() uuid: string
+
+  playlist: VideoPlaylist
+
+  displayOptions: MiniatureDisplayOptions = {
+    date: true,
+    views: true,
+    by: true,
+    avatar: false,
+    privacyLabel: false,
+    privacyText: false,
+    state: false,
+    blacklistInfo: false
+  }
+
+  constructor (
+    private playlistService: VideoPlaylistService
+  ) { }
+
+  ngOnInit () {
+    this.playlistService.getVideoPlaylist(this.uuid)
+      .subscribe(playlist => this.playlist = playlist)
+  }
+}
diff --git a/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts
new file mode 100644 (file)
index 0000000..4bbb715
--- /dev/null
@@ -0,0 +1,49 @@
+
+import { CommonModule } from '@angular/common'
+import { NgModule } from '@angular/core'
+import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { SharedMainModule } from '../shared-main'
+import { SharedVideoMiniatureModule } from '../shared-video-miniature'
+import { SharedVideoPlaylistModule } from '../shared-video-playlist'
+import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
+import { CustomMarkupService } from './custom-markup.service'
+import { DynamicElementService } from './dynamic-element.service'
+import { EmbedMarkupComponent } from './embed-markup.component'
+import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
+import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
+import { VideosListMarkupComponent } from './videos-list-markup.component'
+
+@NgModule({
+  imports: [
+    CommonModule,
+
+    SharedMainModule,
+    SharedGlobalIconModule,
+    SharedVideoMiniatureModule,
+    SharedVideoPlaylistModule,
+    SharedActorImageModule
+  ],
+
+  declarations: [
+    VideoMiniatureMarkupComponent,
+    PlaylistMiniatureMarkupComponent,
+    ChannelMiniatureMarkupComponent,
+    EmbedMarkupComponent,
+    VideosListMarkupComponent
+  ],
+
+  exports: [
+    VideoMiniatureMarkupComponent,
+    PlaylistMiniatureMarkupComponent,
+    ChannelMiniatureMarkupComponent,
+    VideosListMarkupComponent,
+    EmbedMarkupComponent
+  ],
+
+  providers: [
+    CustomMarkupService,
+    DynamicElementService
+  ]
+})
+export class SharedCustomMarkupModule { }
diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html
new file mode 100644 (file)
index 0000000..9b4930b
--- /dev/null
@@ -0,0 +1,6 @@
+<my-video-miniature
+  *ngIf="video"
+  [video]="video" [user]="getUser()" [displayAsRow]="false"
+  [displayVideoActions]="false" [displayOptions]="displayOptions"
+>
+</my-video-miniature>
diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss
new file mode 100644 (file)
index 0000000..81e265f
--- /dev/null
@@ -0,0 +1,7 @@
+@import '_variables';
+@import '_mixins';
+
+my-video-miniature {
+  display: inline-block;
+  width: $video-thumbnail-width;
+}
diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts
new file mode 100644 (file)
index 0000000..79add0c
--- /dev/null
@@ -0,0 +1,44 @@
+import { Component, Input, OnInit } from '@angular/core'
+import { AuthService } from '@app/core'
+import { Video, VideoService } from '../shared-main'
+import { MiniatureDisplayOptions } from '../shared-video-miniature'
+
+/*
+ * Markup component that creates a video miniature only
+*/
+
+@Component({
+  selector: 'my-video-miniature-markup',
+  templateUrl: 'video-miniature-markup.component.html',
+  styleUrls: [ 'video-miniature-markup.component.scss' ]
+})
+export class VideoMiniatureMarkupComponent implements OnInit {
+  @Input() uuid: string
+
+  video: Video
+
+  displayOptions: MiniatureDisplayOptions = {
+    date: true,
+    views: true,
+    by: true,
+    avatar: false,
+    privacyLabel: false,
+    privacyText: false,
+    state: false,
+    blacklistInfo: false
+  }
+
+  constructor (
+    private auth: AuthService,
+    private videoService: VideoService
+  ) { }
+
+  getUser () {
+    return this.auth.getUser()
+  }
+
+  ngOnInit () {
+    this.videoService.getVideo({ videoId: this.uuid })
+      .subscribe(video => this.video = video)
+  }
+}
diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html
new file mode 100644 (file)
index 0000000..501f35e
--- /dev/null
@@ -0,0 +1,13 @@
+<div class="root">
+  <h4 *ngIf="title">{{ title }}</h4>
+  <div *ngIf="description" class="description">{{ description }}</div>
+
+  <div class="videos">
+    <my-video-miniature
+      *ngFor="let video of videos"
+      [video]="video" [user]="getUser()" [displayAsRow]="false"
+      [displayVideoActions]="false" [displayOptions]="displayOptions"
+    >
+    </my-video-miniature>
+  </div>
+</div>
diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss
new file mode 100644 (file)
index 0000000..dcd9310
--- /dev/null
@@ -0,0 +1,9 @@
+@import '_variables';
+@import '_mixins';
+
+my-video-miniature {
+  margin-right: 15px;
+  display: inline-block;
+  min-width: $video-thumbnail-width;
+  max-width: $video-thumbnail-width;
+}
diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts
new file mode 100644 (file)
index 0000000..cc25d0a
--- /dev/null
@@ -0,0 +1,60 @@
+import { Component, Input, OnInit } from '@angular/core'
+import { AuthService } from '@app/core'
+import { VideoSortField } from '@shared/models'
+import { Video, VideoService } from '../shared-main'
+import { MiniatureDisplayOptions } from '../shared-video-miniature'
+
+/*
+ * Markup component list videos depending on criterias
+*/
+
+@Component({
+  selector: 'my-videos-list-markup',
+  templateUrl: 'videos-list-markup.component.html',
+  styleUrls: [ 'videos-list-markup.component.scss' ]
+})
+export class VideosListMarkupComponent implements OnInit {
+  @Input() title: string
+  @Input() description: string
+  @Input() sort = '-publishedAt'
+  @Input() categoryOneOf: number[]
+  @Input() languageOneOf: string[]
+  @Input() count = 10
+
+  videos: Video[]
+
+  displayOptions: MiniatureDisplayOptions = {
+    date: true,
+    views: true,
+    by: true,
+    avatar: false,
+    privacyLabel: false,
+    privacyText: false,
+    state: false,
+    blacklistInfo: false
+  }
+
+  constructor (
+    private auth: AuthService,
+    private videoService: VideoService
+  ) { }
+
+  getUser () {
+    return this.auth.getUser()
+  }
+
+  ngOnInit () {
+    const options = {
+      videoPagination: {
+        currentPage: 1,
+        itemsPerPage: this.count
+      },
+      categoryOneOf: this.categoryOneOf,
+      languageOneOf: this.languageOneOf,
+      sort: this.sort as VideoSortField
+    }
+
+    this.videoService.getVideos(options)
+      .subscribe(({ data }) => this.videos = data)
+  }
+}
index 513b543cd5c325a6778a51a3e8ffbf4150eb52ad..6e70e2f37570b4411c2ea585122aea6490f92140 100644 (file)
@@ -19,6 +19,7 @@
       <a ngbNavLink i18n>Complete preview</a>
 
       <ng-template ngbNavContent>
+        <div #previewElement></div>
         <div [innerHTML]="previewHTML"></div>
       </ng-template>
     </ng-container>
index 9b3ab9cf3f4719f7e7532a826bdaecdf294ac3eb..a233a42050cb348daa5137742a3bfacb349565bf 100644 (file)
@@ -1,9 +1,10 @@
-import { ViewportScroller } from '@angular/common'
 import truncate from 'lodash-es/truncate'
 import { Subject } from 'rxjs'
 import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
+import { ViewportScroller } from '@angular/common'
 import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { SafeHtml } from '@angular/platform-browser'
 import { MarkdownService, ScreenService } from '@app/core'
 
 @Component({
@@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core'
 
 export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
   @Input() content = ''
+
   @Input() classes: string[] | { [klass: string]: any[] | any } = []
+
   @Input() textareaMaxWidth = '100%'
   @Input() textareaHeight = '150px'
+
   @Input() truncate: number
+
   @Input() markdownType: 'text' | 'enhanced' = 'text'
+  @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
+
   @Input() markdownVideo = false
+
   @Input() name = 'description'
 
   @ViewChild('textarea') textareaElement: ElementRef
+  @ViewChild('previewElement') previewElement: ElementRef
+
+  truncatedPreviewHTML: SafeHtml | string = ''
+  previewHTML: SafeHtml | string = ''
 
-  truncatedPreviewHTML = ''
-  previewHTML = ''
   isMaximized = false
 
   maximizeInText = $localize`Maximize editor`
@@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
   }
 
   private async markdownRender (text: string) {
-    const html = this.markdownType === 'text' ?
-      await this.markdownService.textMarkdownToHTML(text) :
-      await this.markdownService.enhancedMarkdownToHTML(text)
+    let html: string
+
+    if (this.customMarkdownRenderer) {
+      const result = await this.customMarkdownRenderer(text)
+
+      if (result instanceof HTMLElement) {
+        html = ''
+
+        const wrapperElement = this.previewElement.nativeElement as HTMLElement
+        wrapperElement.innerHTML = ''
+        wrapperElement.appendChild(result)
+        return
+      }
+
+      html = result
+    } else if (this.markdownType === 'text') {
+      html = await this.markdownService.textMarkdownToHTML(text)
+    } else {
+      html = await this.markdownService.enhancedMarkdownToHTML(text)
+    }
+
+    if (this.markdownVideo) {
+      html = this.markdownService.processVideoTimestamps(html)
+    }
 
-    return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html
+    return html
   }
 }
index 3af517927aeb34866dd01f6a97bb3aee843e08e2..a4dd72db6e06b1e08cb16cedf5d0f9a5600d93ed 100644 (file)
@@ -72,6 +72,7 @@ const icons = {
   'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
   'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
   'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
+  'octagon': require('!!raw-loader?!../../../assets/images/feather/octagon.svg').default,
   'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default
 }
 
diff --git a/client/src/app/shared/shared-main/custom-page/custom-page.service.ts b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts
new file mode 100644 (file)
index 0000000..e5c2b3c
--- /dev/null
@@ -0,0 +1,38 @@
+import { of } from 'rxjs'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { CustomPage } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+
+@Injectable()
+export class CustomPageService {
+  static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) { }
+
+  getInstanceHomepage () {
+    return this.authHttp.get<CustomPage>(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL)
+                        .pipe(
+                          catchError(err => {
+                            if (err.status === 404) {
+                              return of({ content: '' })
+                            }
+
+                            this.restExtractor.handleError(err)
+                          })
+                        )
+  }
+
+  updateInstanceHomepage (content: string) {
+    return this.authHttp.put(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL, { content })
+      .pipe(
+        map(this.restExtractor.extractDataBool),
+        catchError(err => this.restExtractor.handleError(err))
+      )
+  }
+}
diff --git a/client/src/app/shared/shared-main/custom-page/index.ts b/client/src/app/shared/shared-main/custom-page/index.ts
new file mode 100644 (file)
index 0000000..7269ece
--- /dev/null
@@ -0,0 +1 @@
+export * from './custom-page.service'
index 772198cb26e9d43ddac863095658187cb825202b..f9b6085cf70e10468035d4fe7a877a6a4987f4e7 100644 (file)
@@ -29,6 +29,7 @@ import {
 } from './angular'
 import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
 import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
+import { CustomPageService } from './custom-page'
 import { DateToggleComponent } from './date'
 import { FeedComponent } from './feeds'
 import { LoaderComponent, SmallLoaderComponent } from './loaders'
@@ -171,7 +172,9 @@ import { VideoChannelService } from './video-channel'
 
     VideoCaptionService,
 
-    VideoChannelService
+    VideoChannelService,
+
+    CustomPageService
   ]
 })
 export class SharedMainModule { }
diff --git a/client/src/assets/images/feather/octagon.svg b/client/src/assets/images/feather/octagon.svg
new file mode 100644 (file)
index 0000000..1ed9bac
--- /dev/null
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-octagon">
+  <polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon>
+</svg>
index d7451fa1d1fcf3f3c1037f28cb5cf6fdd6140003..1243526d24f4743751fe39c5ac89ad7fdcb3302d 100644 (file)
@@ -95,7 +95,7 @@ function buildVideoLink (options: {
 function buildPlaylistLink (options: {
   baseUrl?: string
 
-  playlistPosition: number
+  playlistPosition?: number
 }) {
   const { baseUrl } = options
 
index 97dffe7567e6a9758ca61f1c81dd4f83374248aa..7aaf1e553626049518e46cdec1e84ec51f5454e0 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -127,6 +127,7 @@ import { PluginManager } from './server/lib/plugins/plugin-manager'
 import { LiveManager } from './server/lib/live-manager'
 import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes'
 import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
+import { ServerConfigManager } from '@server/lib/server-config-manager'
 
 // ----------- Command line -----------
 
@@ -262,7 +263,8 @@ async function startApplication () {
 
   await Promise.all([
     Emailer.Instance.checkConnection(),
-    JobQueue.Instance.init()
+    JobQueue.Instance.init(),
+    ServerConfigManager.Instance.init()
   ])
 
   // Caches initializations
index 5ce7adc35e25972cd4d8b9ec068048a8d8f85e7a..c9b5c8047d8a12fb1c7e16e97f454198a4d5fca8 100644 (file)
@@ -1,8 +1,8 @@
+import { ServerConfigManager } from '@server/lib/server-config-manager'
 import * as express from 'express'
 import { remove, writeJSON } from 'fs-extra'
 import { snakeCase } from 'lodash'
 import validator from 'validator'
-import { getServerConfig } from '@server/lib/config'
 import { UserRight } from '../../../shared'
 import { About } from '../../../shared/models/server/about.model'
 import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@@ -43,7 +43,7 @@ configRouter.delete('/custom',
 )
 
 async function getConfig (req: express.Request, res: express.Response) {
-  const json = await getServerConfig(req.ip)
+  const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
 
   return res.json(json)
 }
diff --git a/server/controllers/api/custom-page.ts b/server/controllers/api/custom-page.ts
new file mode 100644 (file)
index 0000000..3c47f7b
--- /dev/null
@@ -0,0 +1,42 @@
+import * as express from 'express'
+import { ServerConfigManager } from '@server/lib/server-config-manager'
+import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
+import { HttpStatusCode } from '@shared/core-utils'
+import { UserRight } from '@shared/models'
+import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares'
+
+const customPageRouter = express.Router()
+
+customPageRouter.get('/homepage/instance',
+  asyncMiddleware(getInstanceHomepage)
+)
+
+customPageRouter.put('/homepage/instance',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_INSTANCE_CUSTOM_PAGE),
+  asyncMiddleware(updateInstanceHomepage)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  customPageRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function getInstanceHomepage (req: express.Request, res: express.Response) {
+  const page = await ActorCustomPageModel.loadInstanceHomepage()
+  if (!page) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
+
+  return res.json(page.toFormattedJSON())
+}
+
+async function updateInstanceHomepage (req: express.Request, res: express.Response) {
+  const content = req.body.content
+
+  await ActorCustomPageModel.updateInstanceHomepage(content)
+  ServerConfigManager.Instance.updateHomepageState(content)
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
index 7ade1df3a9a3a84b42cb44e6ff83f5f045532f92..28378654ab1175165b44de221f62614aaa37141c 100644 (file)
@@ -8,6 +8,7 @@ import { abuseRouter } from './abuse'
 import { accountsRouter } from './accounts'
 import { bulkRouter } from './bulk'
 import { configRouter } from './config'
+import { customPageRouter } from './custom-page'
 import { jobsRouter } from './jobs'
 import { oauthClientsRouter } from './oauth-clients'
 import { overviewsRouter } from './overviews'
@@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter)
 apiRouter.use('/search', searchRouter)
 apiRouter.use('/overviews', overviewsRouter)
 apiRouter.use('/plugins', pluginRouter)
+apiRouter.use('/custom-pages', customPageRouter)
 apiRouter.use('/ping', pong)
 apiRouter.use('/*', badRequest)
 
index ee63c7b777dacb935beeb9198c76bf2efc096651..0d5d7a962aef3ef9dfe74f078e29767e93ec3bbf 100644 (file)
@@ -3,7 +3,7 @@ import { move, readFile } from 'fs-extra'
 import * as magnetUtil from 'magnet-uri'
 import * as parseTorrent from 'parse-torrent'
 import { join } from 'path'
-import { getEnabledResolutions } from '@server/lib/config'
+import { ServerConfigManager } from '@server/lib/server-config-manager'
 import { setVideoTags } from '@server/lib/video'
 import { FilteredModelAttributes } from '@server/types'
 import {
@@ -134,7 +134,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
   const targetUrl = body.targetUrl
   const user = res.locals.oauth.token.User
 
-  const youtubeDL = new YoutubeDL(targetUrl, getEnabledResolutions('vod'))
+  const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
 
   // Get video infos
   let youtubeDLInfo: YoutubeDLInfo
index 8a747ec52cbfeadd8657b27f0c75a175f0dc2a5a..3870ebfe9bd942e2b41dc8c5d0e3446e204b1071 100644 (file)
@@ -2,7 +2,7 @@ import * as cors from 'cors'
 import * as express from 'express'
 import { join } from 'path'
 import { serveIndexHTML } from '@server/lib/client-html'
-import { getEnabledResolutions, getRegisteredPlugins, getRegisteredThemes } from '@server/lib/config'
+import { ServerConfigManager } from '@server/lib/server-config-manager'
 import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
 import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
 import { root } from '../helpers/core-utils'
@@ -203,10 +203,10 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
             }
           },
           plugin: {
-            registered: getRegisteredPlugins()
+            registered: ServerConfigManager.Instance.getRegisteredPlugins()
           },
           theme: {
-            registered: getRegisteredThemes(),
+            registered: ServerConfigManager.Instance.getRegisteredThemes(),
             default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
           },
           email: {
@@ -222,13 +222,13 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
             webtorrent: {
               enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
             },
-            enabledResolutions: getEnabledResolutions('vod')
+            enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
           },
           live: {
             enabled: CONFIG.LIVE.ENABLED,
             transcoding: {
               enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
-              enabledResolutions: getEnabledResolutions('live')
+              enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
             }
           },
           import: {
index 2126bb7526d00054cc5131e5aa56e4f069f2f87b..41e57d857badfb6173f9faa56d9cad9eecdfe065 100644 (file)
@@ -1,4 +1,6 @@
-import { SANITIZE_OPTIONS, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
+import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
+
+const sanitizeOptions = getSanitizeOptions()
 
 const sanitizeHtml = require('sanitize-html')
 const markdownItEmoji = require('markdown-it-emoji/light')
@@ -18,7 +20,7 @@ const toSafeHtml = text => {
   const html = markdownIt.render(textWithLineFeed)
 
   // Convert to safe Html
-  return sanitizeHtml(html, SANITIZE_OPTIONS)
+  return sanitizeHtml(html, sanitizeOptions)
 }
 
 const mdToPlainText = text => {
@@ -28,7 +30,7 @@ const mdToPlainText = text => {
   const html = markdownIt.render(text)
 
   // Convert to safe Html
-  const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS)
+  const safeHtml = sanitizeHtml(html, sanitizeOptions)
 
   return safeHtml.replace(/<[^>]+>/g, '')
                  .replace(/\n$/, '')
index 4cf7dcf0a9b0669c98a893ec1a6c27a2b1fc2f66..919f9ea6e35c764efd2ad2f925b8540bcd18cc3c 100644 (file)
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 645
+const LAST_MIGRATION_VERSION = 650
 
 // ---------------------------------------------------------------------------
 
index 75a13ec8b0a69268e1ea457a267a79f431b83fc8..38e7a76d0b213afbcc849a5858f904bdde4d8e77 100644 (file)
@@ -44,6 +44,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
 import { VideoTagModel } from '../models/video/video-tag'
 import { VideoViewModel } from '../models/video/video-view'
 import { CONFIG } from './config'
+import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) {
     ThumbnailModel,
     TrackerModel,
     VideoTrackerModel,
-    PluginModel
+    PluginModel,
+    ActorCustomPageModel
   ])
 
   // Check extensions exist in the database
diff --git a/server/initializers/migrations/0650-actor-custom-pages.ts b/server/initializers/migrations/0650-actor-custom-pages.ts
new file mode 100644 (file)
index 0000000..1338327
--- /dev/null
@@ -0,0 +1,33 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+  db: any
+}): Promise<void> {
+  {
+    const query = `
+    CREATE TABLE IF NOT EXISTS "actorCustomPage" (
+      "id" serial,
+      "content" TEXT,
+      "type" varchar(255) NOT NULL,
+      "actorId" integer NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+      "createdAt" timestamp WITH time zone NOT NULL,
+      "updatedAt" timestamp WITH time zone NOT NULL,
+      PRIMARY KEY ("id")
+    );
+    `
+
+    await utils.sequelize.query(query)
+  }
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index 85fdc87545ab98324254304d9a62cfa9c8ec83fb..4b2968e8bd4ccecdfc6c841710106b5247209cce 100644 (file)
@@ -26,7 +26,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
 import { getActivityStreamDuration } from '../models/video/video-format-utils'
 import { VideoPlaylistModel } from '../models/video/video-playlist'
 import { MAccountActor, MChannelActor } from '../types/models'
-import { getHTMLServerConfig } from './config'
+import { ServerConfigManager } from './server-config-manager'
 
 type Tags = {
   ogType: string
@@ -211,7 +211,7 @@ class ClientHtml {
     if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
 
     const buffer = await readFile(path)
-    const serverConfig = await getHTMLServerConfig()
+    const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
 
     let html = buffer.toString()
     html = await ClientHtml.addAsyncPluginCSS(html)
@@ -280,7 +280,7 @@ class ClientHtml {
     if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
 
     const buffer = await readFile(path)
-    const serverConfig = await getHTMLServerConfig()
+    const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
 
     let html = buffer.toString()
 
diff --git a/server/lib/config.ts b/server/lib/config.ts
deleted file mode 100644 (file)
index 18d49f0..0000000
+++ /dev/null
@@ -1,274 +0,0 @@
-import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup'
-import { getServerCommit } from '@server/helpers/utils'
-import { CONFIG, isEmailEnabled } from '@server/initializers/config'
-import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
-import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
-import { Hooks } from './plugins/hooks'
-import { PluginManager } from './plugins/plugin-manager'
-import { getThemeOrDefault } from './plugins/theme-utils'
-import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
-
-async function getServerConfig (ip?: string): Promise<ServerConfig> {
-  const { allowed } = await Hooks.wrapPromiseFun(
-    isSignupAllowed,
-    {
-      ip
-    },
-    'filter:api.user.signup.allowed.result'
-  )
-
-  const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
-
-  const signup = {
-    allowed,
-    allowedForCurrentIP,
-    requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
-  }
-
-  const htmlConfig = await getHTMLServerConfig()
-
-  return { ...htmlConfig, signup }
-}
-
-// Config injected in HTML
-let serverCommit: string
-async function getHTMLServerConfig (): Promise<HTMLServerConfig> {
-  if (serverCommit === undefined) serverCommit = await getServerCommit()
-
-  const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
-
-  return {
-    instance: {
-      name: CONFIG.INSTANCE.NAME,
-      shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
-      isNSFW: CONFIG.INSTANCE.IS_NSFW,
-      defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
-      defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
-      customizations: {
-        javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
-        css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
-      }
-    },
-    search: {
-      remoteUri: {
-        users: CONFIG.SEARCH.REMOTE_URI.USERS,
-        anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
-      },
-      searchIndex: {
-        enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
-        url: CONFIG.SEARCH.SEARCH_INDEX.URL,
-        disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
-        isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
-      }
-    },
-    plugin: {
-      registered: getRegisteredPlugins(),
-      registeredExternalAuths: getExternalAuthsPlugins(),
-      registeredIdAndPassAuths: getIdAndPassAuthPlugins()
-    },
-    theme: {
-      registered: getRegisteredThemes(),
-      default: defaultTheme
-    },
-    email: {
-      enabled: isEmailEnabled()
-    },
-    contactForm: {
-      enabled: CONFIG.CONTACT_FORM.ENABLED
-    },
-    serverVersion: PEERTUBE_VERSION,
-    serverCommit,
-    transcoding: {
-      hls: {
-        enabled: CONFIG.TRANSCODING.HLS.ENABLED
-      },
-      webtorrent: {
-        enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
-      },
-      enabledResolutions: getEnabledResolutions('vod'),
-      profile: CONFIG.TRANSCODING.PROFILE,
-      availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
-    },
-    live: {
-      enabled: CONFIG.LIVE.ENABLED,
-
-      allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
-      maxDuration: CONFIG.LIVE.MAX_DURATION,
-      maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
-      maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
-
-      transcoding: {
-        enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
-        enabledResolutions: getEnabledResolutions('live'),
-        profile: CONFIG.LIVE.TRANSCODING.PROFILE,
-        availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
-      },
-
-      rtmp: {
-        port: CONFIG.LIVE.RTMP.PORT
-      }
-    },
-    import: {
-      videos: {
-        http: {
-          enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
-        },
-        torrent: {
-          enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
-        }
-      }
-    },
-    autoBlacklist: {
-      videos: {
-        ofUsers: {
-          enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
-        }
-      }
-    },
-    avatar: {
-      file: {
-        size: {
-          max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
-        },
-        extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
-      }
-    },
-    banner: {
-      file: {
-        size: {
-          max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
-        },
-        extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
-      }
-    },
-    video: {
-      image: {
-        extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
-        size: {
-          max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
-        }
-      },
-      file: {
-        extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
-      }
-    },
-    videoCaption: {
-      file: {
-        size: {
-          max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
-        },
-        extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
-      }
-    },
-    user: {
-      videoQuota: CONFIG.USER.VIDEO_QUOTA,
-      videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
-    },
-    trending: {
-      videos: {
-        intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
-        algorithms: {
-          enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
-          default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
-        }
-      }
-    },
-    tracker: {
-      enabled: CONFIG.TRACKER.ENABLED
-    },
-
-    followings: {
-      instance: {
-        autoFollowIndex: {
-          indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
-        }
-      }
-    },
-
-    broadcastMessage: {
-      enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
-      message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
-      level: CONFIG.BROADCAST_MESSAGE.LEVEL,
-      dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
-    }
-  }
-}
-
-function getRegisteredThemes () {
-  return PluginManager.Instance.getRegisteredThemes()
-                      .map(t => ({
-                        name: t.name,
-                        version: t.version,
-                        description: t.description,
-                        css: t.css,
-                        clientScripts: t.clientScripts
-                      }))
-}
-
-function getRegisteredPlugins () {
-  return PluginManager.Instance.getRegisteredPlugins()
-                      .map(p => ({
-                        name: p.name,
-                        version: p.version,
-                        description: p.description,
-                        clientScripts: p.clientScripts
-                      }))
-}
-
-function getEnabledResolutions (type: 'vod' | 'live') {
-  const transcoding = type === 'vod'
-    ? CONFIG.TRANSCODING
-    : CONFIG.LIVE.TRANSCODING
-
-  return Object.keys(transcoding.RESOLUTIONS)
-               .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
-               .map(r => parseInt(r, 10))
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  getServerConfig,
-  getRegisteredThemes,
-  getEnabledResolutions,
-  getRegisteredPlugins,
-  getHTMLServerConfig
-}
-
-// ---------------------------------------------------------------------------
-
-function getIdAndPassAuthPlugins () {
-  const result: RegisteredIdAndPassAuthConfig[] = []
-
-  for (const p of PluginManager.Instance.getIdAndPassAuths()) {
-    for (const auth of p.idAndPassAuths) {
-      result.push({
-        npmName: p.npmName,
-        name: p.name,
-        version: p.version,
-        authName: auth.authName,
-        weight: auth.getWeight()
-      })
-    }
-  }
-
-  return result
-}
-
-function getExternalAuthsPlugins () {
-  const result: RegisteredExternalAuthConfig[] = []
-
-  for (const p of PluginManager.Instance.getExternalAuths()) {
-    for (const auth of p.externalAuths) {
-      result.push({
-        npmName: p.npmName,
-        name: p.name,
-        version: p.version,
-        authName: auth.authName,
-        authDisplayName: auth.authDisplayName()
-      })
-    }
-  }
-
-  return result
-}
index 3067ce214288f1df0f6d4ac47a6ed000691753b3..d71053e8703286e11912d518c3a45afa06fdfd91 100644 (file)
@@ -2,8 +2,10 @@ import * as Bull from 'bull'
 import { move, remove, stat } from 'fs-extra'
 import { extname } from 'path'
 import { retryTransactionWrapper } from '@server/helpers/database-utils'
+import { YoutubeDL } from '@server/helpers/youtube-dl'
 import { isPostImportVideoAccepted } from '@server/lib/moderation'
 import { Hooks } from '@server/lib/plugins/hooks'
+import { ServerConfigManager } from '@server/lib/server-config-manager'
 import { isAbleToUploadVideo } from '@server/lib/user'
 import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
 import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
@@ -33,8 +35,6 @@ import { MThumbnail } from '../../../types/models/video/thumbnail'
 import { federateVideoIfNeeded } from '../../activitypub/videos'
 import { Notifier } from '../../notifier'
 import { generateVideoMiniature } from '../../thumbnail'
-import { YoutubeDL } from '@server/helpers/youtube-dl'
-import { getEnabledResolutions } from '@server/lib/config'
 
 async function processVideoImport (job: Bull.Job) {
   const payload = job.data as VideoImportPayload
@@ -76,7 +76,7 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub
     videoImportId: videoImport.id
   }
 
-  const youtubeDL = new YoutubeDL(videoImport.targetUrl, getEnabledResolutions('vod'))
+  const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
 
   return processFile(
     () => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT),
index cb1cd4d9a2c0097c29f5a31d52d98eb603345a65..8487672bacd0ade440164b504585dc059d87acb3 100644 (file)
@@ -15,7 +15,7 @@ import { MPlugin } from '@server/types/models'
 import { PeerTubeHelpers } from '@server/types/plugins'
 import { VideoBlacklistCreate } from '@shared/models'
 import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
-import { getServerConfig } from '../config'
+import { ServerConfigManager } from '../server-config-manager'
 import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
 import { UserModel } from '@server/models/user/user'
 
@@ -147,7 +147,7 @@ function buildConfigHelpers () {
     },
 
     getServerConfig () {
-      return getServerConfig()
+      return ServerConfigManager.Instance.getServerConfig()
     }
   }
 }
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
new file mode 100644 (file)
index 0000000..1aff6f4
--- /dev/null
@@ -0,0 +1,303 @@
+import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup'
+import { getServerCommit } from '@server/helpers/utils'
+import { CONFIG, isEmailEnabled } from '@server/initializers/config'
+import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
+import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
+import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
+import { Hooks } from './plugins/hooks'
+import { PluginManager } from './plugins/plugin-manager'
+import { getThemeOrDefault } from './plugins/theme-utils'
+import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
+
+/**
+ *
+ * Used to send the server config to clients (using REST/API or plugins API)
+ * We need a singleton class to manage config state depending on external events (to build menu entries etc)
+ *
+ */
+
+class ServerConfigManager {
+
+  private static instance: ServerConfigManager
+
+  private serverCommit: string
+
+  private homepageEnabled = false
+
+  private constructor () {}
+
+  async init () {
+    const instanceHomepage = await ActorCustomPageModel.loadInstanceHomepage()
+
+    this.updateHomepageState(instanceHomepage?.content)
+  }
+
+  updateHomepageState (content: string) {
+    this.homepageEnabled = !!content
+  }
+
+  async getHTMLServerConfig (): Promise<HTMLServerConfig> {
+    if (this.serverCommit === undefined) this.serverCommit = await getServerCommit()
+
+    const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
+
+    return {
+      instance: {
+        name: CONFIG.INSTANCE.NAME,
+        shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
+        isNSFW: CONFIG.INSTANCE.IS_NSFW,
+        defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
+        defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
+        customizations: {
+          javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
+          css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
+        }
+      },
+      search: {
+        remoteUri: {
+          users: CONFIG.SEARCH.REMOTE_URI.USERS,
+          anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
+        },
+        searchIndex: {
+          enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
+          url: CONFIG.SEARCH.SEARCH_INDEX.URL,
+          disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
+          isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
+        }
+      },
+      plugin: {
+        registered: this.getRegisteredPlugins(),
+        registeredExternalAuths: this.getExternalAuthsPlugins(),
+        registeredIdAndPassAuths: this.getIdAndPassAuthPlugins()
+      },
+      theme: {
+        registered: this.getRegisteredThemes(),
+        default: defaultTheme
+      },
+      email: {
+        enabled: isEmailEnabled()
+      },
+      contactForm: {
+        enabled: CONFIG.CONTACT_FORM.ENABLED
+      },
+      serverVersion: PEERTUBE_VERSION,
+      serverCommit: this.serverCommit,
+      transcoding: {
+        hls: {
+          enabled: CONFIG.TRANSCODING.HLS.ENABLED
+        },
+        webtorrent: {
+          enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
+        },
+        enabledResolutions: this.getEnabledResolutions('vod'),
+        profile: CONFIG.TRANSCODING.PROFILE,
+        availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
+      },
+      live: {
+        enabled: CONFIG.LIVE.ENABLED,
+
+        allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
+        maxDuration: CONFIG.LIVE.MAX_DURATION,
+        maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
+        maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
+
+        transcoding: {
+          enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
+          enabledResolutions: this.getEnabledResolutions('live'),
+          profile: CONFIG.LIVE.TRANSCODING.PROFILE,
+          availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
+        },
+
+        rtmp: {
+          port: CONFIG.LIVE.RTMP.PORT
+        }
+      },
+      import: {
+        videos: {
+          http: {
+            enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
+          },
+          torrent: {
+            enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
+          }
+        }
+      },
+      autoBlacklist: {
+        videos: {
+          ofUsers: {
+            enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
+          }
+        }
+      },
+      avatar: {
+        file: {
+          size: {
+            max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
+          },
+          extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
+        }
+      },
+      banner: {
+        file: {
+          size: {
+            max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
+          },
+          extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
+        }
+      },
+      video: {
+        image: {
+          extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
+          size: {
+            max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
+          }
+        },
+        file: {
+          extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
+        }
+      },
+      videoCaption: {
+        file: {
+          size: {
+            max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
+          },
+          extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
+        }
+      },
+      user: {
+        videoQuota: CONFIG.USER.VIDEO_QUOTA,
+        videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
+      },
+      trending: {
+        videos: {
+          intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
+          algorithms: {
+            enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
+            default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
+          }
+        }
+      },
+      tracker: {
+        enabled: CONFIG.TRACKER.ENABLED
+      },
+
+      followings: {
+        instance: {
+          autoFollowIndex: {
+            indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
+          }
+        }
+      },
+
+      broadcastMessage: {
+        enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
+        message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
+        level: CONFIG.BROADCAST_MESSAGE.LEVEL,
+        dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
+      },
+
+      homepage: {
+        enabled: this.homepageEnabled
+      }
+    }
+  }
+
+  async getServerConfig (ip?: string): Promise<ServerConfig> {
+    const { allowed } = await Hooks.wrapPromiseFun(
+      isSignupAllowed,
+      {
+        ip
+      },
+      'filter:api.user.signup.allowed.result'
+    )
+
+    const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
+
+    const signup = {
+      allowed,
+      allowedForCurrentIP,
+      requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
+    }
+
+    const htmlConfig = await this.getHTMLServerConfig()
+
+    return { ...htmlConfig, signup }
+  }
+
+  getRegisteredThemes () {
+    return PluginManager.Instance.getRegisteredThemes()
+                        .map(t => ({
+                          name: t.name,
+                          version: t.version,
+                          description: t.description,
+                          css: t.css,
+                          clientScripts: t.clientScripts
+                        }))
+  }
+
+  getRegisteredPlugins () {
+    return PluginManager.Instance.getRegisteredPlugins()
+                        .map(p => ({
+                          name: p.name,
+                          version: p.version,
+                          description: p.description,
+                          clientScripts: p.clientScripts
+                        }))
+  }
+
+  getEnabledResolutions (type: 'vod' | 'live') {
+    const transcoding = type === 'vod'
+      ? CONFIG.TRANSCODING
+      : CONFIG.LIVE.TRANSCODING
+
+    return Object.keys(transcoding.RESOLUTIONS)
+                 .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
+                 .map(r => parseInt(r, 10))
+  }
+
+  private getIdAndPassAuthPlugins () {
+    const result: RegisteredIdAndPassAuthConfig[] = []
+
+    for (const p of PluginManager.Instance.getIdAndPassAuths()) {
+      for (const auth of p.idAndPassAuths) {
+        result.push({
+          npmName: p.npmName,
+          name: p.name,
+          version: p.version,
+          authName: auth.authName,
+          weight: auth.getWeight()
+        })
+      }
+    }
+
+    return result
+  }
+
+  private getExternalAuthsPlugins () {
+    const result: RegisteredExternalAuthConfig[] = []
+
+    for (const p of PluginManager.Instance.getExternalAuths()) {
+      for (const auth of p.externalAuths) {
+        result.push({
+          npmName: p.npmName,
+          name: p.name,
+          version: p.version,
+          authName: auth.authName,
+          authDisplayName: auth.authDisplayName()
+        })
+      }
+    }
+
+    return result
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  ServerConfigManager
+}
diff --git a/server/models/account/actor-custom-page.ts b/server/models/account/actor-custom-page.ts
new file mode 100644 (file)
index 0000000..8930231
--- /dev/null
@@ -0,0 +1,69 @@
+import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { CustomPage } from '@shared/models'
+import { ActorModel } from '../actor/actor'
+import { getServerActor } from '../application/application'
+
+@Table({
+  tableName: 'actorCustomPage',
+  indexes: [
+    {
+      fields: [ 'actorId', 'type' ],
+      unique: true
+    }
+  ]
+})
+export class ActorCustomPageModel extends Model {
+
+  @AllowNull(true)
+  @Column(DataType.TEXT)
+  content: string
+
+  @AllowNull(false)
+  @Column
+  type: 'homepage'
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => ActorModel)
+  @Column
+  actorId: number
+
+  @BelongsTo(() => ActorModel, {
+    foreignKey: {
+      name: 'actorId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  Actor: ActorModel
+
+  static async updateInstanceHomepage (content: string) {
+    const serverActor = await getServerActor()
+
+    return ActorCustomPageModel.upsert({
+      content,
+      actorId: serverActor.id,
+      type: 'homepage'
+    })
+  }
+
+  static async loadInstanceHomepage () {
+    const serverActor = await getServerActor()
+
+    return ActorCustomPageModel.findOne({
+      where: {
+        actorId: serverActor.id
+      }
+    })
+  }
+
+  toFormattedJSON (): CustomPage {
+    return {
+      content: this.content
+    }
+  }
+}
diff --git a/server/tests/api/check-params/custom-pages.ts b/server/tests/api/check-params/custom-pages.ts
new file mode 100644 (file)
index 0000000..74ca338
--- /dev/null
@@ -0,0 +1,81 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
+import {
+  cleanupTests,
+  createUser,
+  flushAndRunServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  userLogin
+} from '../../../../shared/extra-utils'
+import { makeGetRequest, makePutBodyRequest } from '../../../../shared/extra-utils/requests/requests'
+
+describe('Test custom pages validators', function () {
+  const path = '/api/v1/custom-pages/homepage/instance'
+
+  let server: ServerInfo
+  let userAccessToken: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(120000)
+
+    server = await flushAndRunServer(1)
+    await setAccessTokensToServers([ server ])
+
+    const user = { username: 'user1', password: 'password' }
+    await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
+
+    userAccessToken = await userLogin(server, user)
+  })
+
+  describe('When updating instance homepage', function () {
+
+    it('Should fail with an unauthenticated user', async function () {
+      await makePutBodyRequest({
+        url: server.url,
+        path,
+        fields: { content: 'super content' },
+        statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a non admin user', async function () {
+      await makePutBodyRequest({
+        url: server.url,
+        path,
+        token: userAccessToken,
+        fields: { content: 'super content' },
+        statusCodeExpected: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await makePutBodyRequest({
+        url: server.url,
+        path,
+        token: server.accessToken,
+        fields: { content: 'super content' },
+        statusCodeExpected: HttpStatusCode.NO_CONTENT_204
+      })
+    })
+  })
+
+  describe('When getting instance homapage', function () {
+
+    it('Should succeed with the correct params', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        statusCodeExpected: HttpStatusCode.OK_200
+      })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index 1435158383eb4f59d9b23ec181350ea225a70819..ce2335e42306cb923b512f44315a9725f3e25dc0 100644 (file)
@@ -3,6 +3,7 @@ import './accounts'
 import './blocklist'
 import './bulk'
 import './config'
+import './custom-pages'
 import './contact-form'
 import './debug'
 import './follows'
diff --git a/server/tests/api/server/homepage.ts b/server/tests/api/server/homepage.ts
new file mode 100644 (file)
index 0000000..e8ba89c
--- /dev/null
@@ -0,0 +1,85 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import * as chai from 'chai'
+import { HttpStatusCode } from '@shared/core-utils'
+import { CustomPage, ServerConfig } from '@shared/models'
+import {
+  cleanupTests,
+  flushAndRunServer,
+  getConfig,
+  getInstanceHomepage,
+  killallServers,
+  reRunServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  updateInstanceHomepage
+} from '../../../../shared/extra-utils/index'
+
+const expect = chai.expect
+
+async function getHomepageState (server: ServerInfo) {
+  const res = await getConfig(server.url)
+
+  const config = res.body as ServerConfig
+  return config.homepage.enabled
+}
+
+describe('Test instance homepage actions', function () {
+  let server: ServerInfo
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await flushAndRunServer(1)
+    await setAccessTokensToServers([ server ])
+  })
+
+  it('Should not have a homepage', async function () {
+    const state = await getHomepageState(server)
+    expect(state).to.be.false
+
+    await getInstanceHomepage(server.url, HttpStatusCode.NOT_FOUND_404)
+  })
+
+  it('Should set a homepage', async function () {
+    await updateInstanceHomepage(server.url, server.accessToken, '<picsou-magazine></picsou-magazine>')
+
+    const res = await getInstanceHomepage(server.url)
+    const page: CustomPage = res.body
+    expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
+
+    const state = await getHomepageState(server)
+    expect(state).to.be.true
+  })
+
+  it('Should have the same homepage after a restart', async function () {
+    this.timeout(30000)
+
+    killallServers([ server ])
+
+    await reRunServer(server)
+
+    const res = await getInstanceHomepage(server.url)
+    const page: CustomPage = res.body
+    expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
+
+    const state = await getHomepageState(server)
+    expect(state).to.be.true
+  })
+
+  it('Should empty the homepage', async function () {
+    await updateInstanceHomepage(server.url, server.accessToken, '')
+
+    const res = await getInstanceHomepage(server.url)
+    const page: CustomPage = res.body
+    expect(page.content).to.be.empty
+
+    const state = await getHomepageState(server)
+    expect(state).to.be.false
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index be743973a0c37ad595b2ffb33088f36169767c90..56e6eb5da94529e71abda9e799b2946c9611de97 100644 (file)
@@ -5,6 +5,7 @@ import './email'
 import './follow-constraints'
 import './follows'
 import './follows-moderation'
+import './homepage'
 import './handle-down'
 import './jobs'
 import './logs'
diff --git a/server/types/models/account/actor-custom-page.ts b/server/types/models/account/actor-custom-page.ts
new file mode 100644 (file)
index 0000000..2cb8aa7
--- /dev/null
@@ -0,0 +1,4 @@
+
+import { ActorCustomPageModel } from '../../../models/account/actor-custom-page'
+
+export type MActorCustomPage = Omit<ActorCustomPageModel, 'Actor'>
index dab2eea7e39de71c482a6f07e76d4a121bf09915..9679c01e44be8c314e06f7704deee216e78baa0d 100644 (file)
@@ -1,2 +1,3 @@
 export * from './account'
+export * from './actor-custom-page'
 export * from './account-blocklist'
index 71703faacbd39acb32b5031b0502d5ae279c12b0..4780ca922d9d615843493b89a780092f2976a650 100644 (file)
@@ -28,9 +28,24 @@ function isCatchable (value: any) {
   return value && typeof value.catch === 'function'
 }
 
+function sortObjectComparator (key: string, order: 'asc' | 'desc') {
+  return (a: any, b: any) => {
+    if (a[key] < b[key]) {
+      return order === 'asc' ? -1 : 1
+    }
+
+    if (a[key] > b[key]) {
+      return order === 'asc' ? 1 : -1
+    }
+
+    return 0
+  }
+}
+
 export {
   randomInt,
   compareSemVer,
   isPromise,
-  isCatchable
+  isCatchable,
+  sortObjectComparator
 }
index de4ad47ac8569c47d41f89f405fb2af34a273724..bbf8b3fbd6531cc8222f6dcea821e66c57b76443 100644 (file)
@@ -1,25 +1,45 @@
-export const SANITIZE_OPTIONS = {
-  allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
-  allowedSchemes: [ 'http', 'https' ],
-  allowedAttributes: {
-    a: [ 'href', 'class', 'target', 'rel' ]
-  },
-  transformTags: {
-    a: (tagName: string, attribs: any) => {
-      let rel = 'noopener noreferrer'
-      if (attribs.rel === 'me') rel += ' me'
+export function getSanitizeOptions () {
+  return {
+    allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
+    allowedSchemes: [ 'http', 'https' ],
+    allowedAttributes: {
+      'a': [ 'href', 'class', 'target', 'rel' ],
+      '*': [ 'data-*' ]
+    },
+    transformTags: {
+      a: (tagName: string, attribs: any) => {
+        let rel = 'noopener noreferrer'
+        if (attribs.rel === 'me') rel += ' me'
 
-      return {
-        tagName,
-        attribs: Object.assign(attribs, {
-          target: '_blank',
-          rel
-        })
+        return {
+          tagName,
+          attribs: Object.assign(attribs, {
+            target: '_blank',
+            rel
+          })
+        }
       }
     }
   }
 }
 
+export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) {
+  const base = getSanitizeOptions()
+
+  return {
+    allowedTags: [
+      ...base.allowedTags,
+      ...additionalAllowedTags,
+      'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
+    ],
+    allowedSchemes: base.allowedSchemes,
+    allowedAttributes: {
+      ...base.allowedAttributes,
+      '*': [ 'data-*', 'style' ]
+    }
+  }
+}
+
 // Thanks: https://stackoverflow.com/a/12034334
 export function escapeHTML (stringParam: string) {
   if (!stringParam) return ''
diff --git a/shared/extra-utils/custom-pages/custom-pages.ts b/shared/extra-utils/custom-pages/custom-pages.ts
new file mode 100644 (file)
index 0000000..bf2d16c
--- /dev/null
@@ -0,0 +1,31 @@
+import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
+import { makeGetRequest, makePutBodyRequest } from '../requests/requests'
+
+function getInstanceHomepage (url: string, statusCodeExpected = HttpStatusCode.OK_200) {
+  const path = '/api/v1/custom-pages/homepage/instance'
+
+  return makeGetRequest({
+    url,
+    path,
+    statusCodeExpected
+  })
+}
+
+function updateInstanceHomepage (url: string, token: string, content: string) {
+  const path = '/api/v1/custom-pages/homepage/instance'
+
+  return makePutBodyRequest({
+    url,
+    path,
+    token,
+    fields: { content },
+    statusCodeExpected: HttpStatusCode.NO_CONTENT_204
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  getInstanceHomepage,
+  updateInstanceHomepage
+}
index 720db19cb4c683d03973ca32befcbf429160841e..3bc09ead587a11a173a17a260629bb9dc9d8ab3e 100644 (file)
@@ -2,6 +2,8 @@ export * from './bulk/bulk'
 
 export * from './cli/cli'
 
+export * from './custom-pages/custom-pages'
+
 export * from './feeds/feeds'
 
 export * from './mock-servers/mock-instances-index'
diff --git a/shared/models/actors/custom-page.model.ts b/shared/models/actors/custom-page.model.ts
new file mode 100644 (file)
index 0000000..1e33584
--- /dev/null
@@ -0,0 +1,3 @@
+export interface CustomPage {
+  content: string
+}
index 156f8324808e5cee9620f0dcfb9c8d9c6d8dd098..e03f168cdf08d907dec00f9a80a04d4516fd5001 100644 (file)
@@ -2,4 +2,5 @@ export * from './account.model'
 export * from './actor-image.model'
 export * from './actor-image.type'
 export * from './actor.model'
+export * from './custom-page.model'
 export * from './follow.model'
diff --git a/shared/models/custom-markup/custom-markup-data.model.ts b/shared/models/custom-markup/custom-markup-data.model.ts
new file mode 100644 (file)
index 0000000..af69742
--- /dev/null
@@ -0,0 +1,28 @@
+export type EmbedMarkupData = {
+  // Video or playlist uuid
+  uuid: string
+}
+
+export type VideoMiniatureMarkupData = {
+  // Video uuid
+  uuid: string
+}
+
+export type PlaylistMiniatureMarkupData = {
+  // Playlist uuid
+  uuid: string
+}
+
+export type ChannelMiniatureMarkupData = {
+  // Channel name (username)
+  name: string
+}
+
+export type VideosListMarkupData = {
+  title: string
+  description: string
+  sort: string
+  categoryOneOf: string // coma separated values
+  languageOneOf: string // coma separated values
+  count: string
+}
diff --git a/shared/models/custom-markup/index.ts b/shared/models/custom-markup/index.ts
new file mode 100644 (file)
index 0000000..2898dfa
--- /dev/null
@@ -0,0 +1 @@
+export * from './custom-markup-data.model'
index dff5fdf0e1ea4eed5930bb49f6b47c4d5c9b967f..4db1f234efa6cbb038de6ae2a2530d7a2f258379 100644 (file)
@@ -1,6 +1,7 @@
 export * from './activitypub'
 export * from './actors'
 export * from './moderation'
+export * from './custom-markup'
 export * from './bulk'
 export * from './redundancy'
 export * from './users'
index 2c5026b30a3c0ef41b9515a3ecf338046220e632..1667bc0e2add769ce8c8e9473c460ac3f94cb4f9 100644 (file)
@@ -214,6 +214,10 @@ export interface ServerConfig {
     level: BroadcastMessageLevel
     dismissable: boolean
   }
+
+  homepage: {
+    enabled: boolean
+  }
 }
 
 export type HTMLServerConfig = Omit<ServerConfig, 'signup'>
index bbedc9f008e3422f1da43098a76bc8aa3ba2727e..950b22bad14adb69aefbc44020b11e88fb10569c 100644 (file)
@@ -16,6 +16,7 @@ export const enum UserRight {
   MANAGE_JOBS,
 
   MANAGE_CONFIGURATION,
+  MANAGE_INSTANCE_CUSTOM_PAGE,
 
   MANAGE_ACCOUNTS_BLOCKLIST,
   MANAGE_SERVERS_BLOCKLIST,
index 11adf078d3095c0380e2c8aa95c3be5e5ef533eb..74910c3133eb7df54fe89aa6526bddd08ede8731 100644 (file)
@@ -247,6 +247,8 @@ tags:
 
       Administrators can also enable the use of a remote search system, indexing
       videos and channels not could be not federated by the instance.
+  - name: Homepage
+    description: Get and update the custom homepage
   - name: Video Mirroring
     description: |
       PeerTube instances can mirror videos from one another, and help distribute some videos.
@@ -281,6 +283,9 @@ x-tagGroups:
   - name: Search
     tags:
       - Search
+  - name: Custom pages
+    tags:
+      - Homepage
   - name: Moderation
     tags:
       - Abuses
@@ -477,6 +482,40 @@ paths:
         '200':
           description: successful operation
 
+  /custom-pages/homepage/instance:
+    get:
+      summary: Get instance custom homepage
+      tags:
+        - Homepage
+      responses:
+        '404':
+          description: No homepage set
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/CustomHomepage'
+    put:
+      summary: Set instance custom homepage
+      tags:
+        - Homepage
+      security:
+        - OAuth2:
+          - admin
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                content:
+                  type: string
+                  description: content of the homepage, that will be injected in the client
+      responses:
+        '204':
+          description: successful operation
+
   /jobs/{state}:
     get:
       summary: List instance jobs
@@ -5740,6 +5779,12 @@ components:
                     indexUrl:
                       type: string
                       format: url
+        homepage:
+          type: object
+          properties:
+            enabled:
+              type: boolean
+
     ServerConfigAbout:
       properties:
         instance:
@@ -5930,6 +5975,12 @@ components:
                   type: boolean
                 manualApproval:
                   type: boolean
+
+    CustomHomepage:
+      properties:
+        content:
+          type: string
+
     Follow:
       properties:
         id: