aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+about/about-peertube/about-peertube-contributors.component.ts2
-rw-r--r--client/src/app/+admin/admin.module.ts8
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html23
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts26
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts48
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html28
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts25
-rw-r--r--client/src/app/+admin/config/edit-custom-config/index.ts1
-rw-r--r--client/src/app/+home/home-routing.module.ts18
-rw-r--r--client/src/app/+home/home.component.html4
-rw-r--r--client/src/app/+home/home.component.scss3
-rw-r--r--client/src/app/+home/home.component.ts26
-rw-r--r--client/src/app/+home/home.module.ts25
-rw-r--r--client/src/app/+home/index.ts3
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.ts2
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts2
-rw-r--r--client/src/app/app-routing.module.ts4
-rw-r--r--client/src/app/app.component.ts2
-rw-r--r--client/src/app/core/menu/menu.service.ts58
-rw-r--r--client/src/app/core/renderer/html-renderer.service.ts10
-rw-r--r--client/src/app/core/renderer/markdown.service.ts53
-rw-r--r--client/src/app/core/server/server.service.ts7
-rw-r--r--client/src/app/menu/menu.component.html21
-rw-r--r--client/src/app/menu/menu.component.ts23
-rw-r--r--client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html8
-rw-r--r--client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss9
-rw-r--r--client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts26
-rw-r--r--client/src/app/shared/shared-custom-markup/custom-markup.service.ts136
-rw-r--r--client/src/app/shared/shared-custom-markup/dynamic-element.service.ts57
-rw-r--r--client/src/app/shared/shared-custom-markup/embed-markup.component.ts22
-rw-r--r--client/src/app/shared/shared-custom-markup/index.ts3
-rw-r--r--client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html2
-rw-r--r--client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss7
-rw-r--r--client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts38
-rw-r--r--client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts49
-rw-r--r--client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html6
-rw-r--r--client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss7
-rw-r--r--client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts44
-rw-r--r--client/src/app/shared/shared-custom-markup/videos-list-markup.component.html13
-rw-r--r--client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss9
-rw-r--r--client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts60
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.html1
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.ts45
-rw-r--r--client/src/app/shared/shared-icons/global-icon.component.ts1
-rw-r--r--client/src/app/shared/shared-main/custom-page/custom-page.service.ts38
-rw-r--r--client/src/app/shared/shared-main/custom-page/index.ts1
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts5
-rw-r--r--client/src/assets/images/feather/octagon.svg3
-rw-r--r--client/src/assets/player/utils.ts2
-rw-r--r--server.ts4
-rw-r--r--server/controllers/api/config.ts4
-rw-r--r--server/controllers/api/custom-page.ts42
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/videos/import.ts4
-rw-r--r--server/controllers/static.ts10
-rw-r--r--server/helpers/markdown.ts8
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/database.ts4
-rw-r--r--server/initializers/migrations/0650-actor-custom-pages.ts33
-rw-r--r--server/lib/client-html.ts6
-rw-r--r--server/lib/config.ts274
-rw-r--r--server/lib/job-queue/handlers/video-import.ts6
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts4
-rw-r--r--server/lib/server-config-manager.ts303
-rw-r--r--server/models/account/actor-custom-page.ts69
-rw-r--r--server/tests/api/check-params/custom-pages.ts81
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/server/homepage.ts85
-rw-r--r--server/tests/api/server/index.ts1
-rw-r--r--server/types/models/account/actor-custom-page.ts4
-rw-r--r--server/types/models/account/index.ts1
-rw-r--r--shared/core-utils/miscs/miscs.ts17
-rw-r--r--shared/core-utils/renderer/html.ts52
-rw-r--r--shared/extra-utils/custom-pages/custom-pages.ts31
-rw-r--r--shared/extra-utils/index.ts2
-rw-r--r--shared/models/actors/custom-page.model.ts3
-rw-r--r--shared/models/actors/index.ts1
-rw-r--r--shared/models/custom-markup/custom-markup-data.model.ts28
-rw-r--r--shared/models/custom-markup/index.ts1
-rw-r--r--shared/models/index.ts1
-rw-r--r--shared/models/server/server-config.model.ts4
-rw-r--r--shared/models/users/user-right.enum.ts1
-rw-r--r--support/doc/api/openapi.yaml51
84 files changed, 1761 insertions, 407 deletions
diff --git a/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts b/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts
index c45269be4..dd774a4ef 100644
--- a/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts
+++ b/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts
@@ -14,6 +14,6 @@ export class AboutPeertubeContributorsComponent implements OnInit {
14 constructor (private markdownService: MarkdownService) { } 14 constructor (private markdownService: MarkdownService) { }
15 15
16 async ngOnInit () { 16 async ngOnInit () {
17 this.creditsHtml = await this.markdownService.completeMarkdownToHTML(this.markdown) 17 this.creditsHtml = await this.markdownService.unsafeMarkdownToHTML(this.markdown, true)
18 } 18 }
19} 19}
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 45366f9ec..a7fe20b07 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -4,12 +4,13 @@ import { TableModule } from 'primeng/table'
4import { NgModule } from '@angular/core' 4import { NgModule } from '@angular/core'
5import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' 5import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
6import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit' 6import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit'
7import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module'
8import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
7import { SharedFormModule } from '@app/shared/shared-forms' 9import { SharedFormModule } from '@app/shared/shared-forms'
8import { SharedGlobalIconModule } from '@app/shared/shared-icons' 10import { SharedGlobalIconModule } from '@app/shared/shared-icons'
9import { SharedMainModule } from '@app/shared/shared-main' 11import { SharedMainModule } from '@app/shared/shared-main'
10import { SharedModerationModule } from '@app/shared/shared-moderation' 12import { SharedModerationModule } from '@app/shared/shared-moderation'
11import { SharedVideoCommentModule } from '@app/shared/shared-video-comment' 13import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
12import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
13import { AdminRoutingModule } from './admin-routing.module' 14import { AdminRoutingModule } from './admin-routing.module'
14import { AdminComponent } from './admin.component' 15import { AdminComponent } from './admin.component'
15import { 16import {
@@ -18,6 +19,7 @@ import {
18 EditBasicConfigurationComponent, 19 EditBasicConfigurationComponent,
19 EditConfigurationService, 20 EditConfigurationService,
20 EditCustomConfigComponent, 21 EditCustomConfigComponent,
22 EditHomepageComponent,
21 EditInstanceInformationComponent, 23 EditInstanceInformationComponent,
22 EditLiveConfigurationComponent, 24 EditLiveConfigurationComponent,
23 EditVODTranscodingComponent 25 EditVODTranscodingComponent
@@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
53 SharedVideoCommentModule, 55 SharedVideoCommentModule,
54 SharedActorImageModule, 56 SharedActorImageModule,
55 SharedActorImageEditModule, 57 SharedActorImageEditModule,
58 SharedCustomMarkupModule,
56 59
57 TableModule, 60 TableModule,
58 SelectButtonModule, 61 SelectButtonModule,
@@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
100 EditVODTranscodingComponent, 103 EditVODTranscodingComponent,
101 EditLiveConfigurationComponent, 104 EditLiveConfigurationComponent,
102 EditAdvancedConfigurationComponent, 105 EditAdvancedConfigurationComponent,
103 EditInstanceInformationComponent 106 EditInstanceInformationComponent,
107 EditHomepageComponent
104 ], 108 ],
105 109
106 exports: [ 110 exports: [
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
index 84a793ae4..451e6a34a 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
@@ -26,22 +26,13 @@
26 <div class="form-group" formGroupName="instance"> 26 <div class="form-group" formGroupName="instance">
27 <label i18n for="instanceDefaultClientRoute">Landing page</label> 27 <label i18n for="instanceDefaultClientRoute">Landing page</label>
28 28
29 <div class="peertube-select-container"> 29 <my-select-custom-value
30 <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control"> 30 id="instanceDefaultClientRoute"
31 <option i18n value="/videos/overview">Discover videos</option> 31 [items]="defaultLandingPageOptions"
32 32 formControlName="defaultClientRoute"
33 <optgroup i18n-label label="Trending pages"> 33 inputType="text"
34 <option i18n value="/videos/trending">Default trending page</option> 34 [clearable]="false"
35 <option i18n value="/videos/trending?alg=best" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('best')">Best videos</option> 35 ></my-select-custom-value>
36 <option i18n value="/videos/trending?alg=hot" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('hot')">Hot videos</option>
37 <option i18n value="/videos/trending?alg=most-viewed" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-viewed')">Most viewed videos</option>
38 <option i18n value="/videos/trending?alg=most-liked" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-liked')">Most liked videos</option>
39 </optgroup>
40
41 <option i18n value="/videos/recently-added">Recently added videos</option>
42 <option i18n value="/videos/local">Local videos</option>
43 </select>
44 </div>
45 36
46 <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div> 37 <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
47 </div> 38 </div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts
index 34d05f9f3..d50148e7a 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts
@@ -1,7 +1,9 @@
1 1
2import { pairwise } from 'rxjs/operators' 2import { pairwise } from 'rxjs/operators'
3import { Component, Input, OnInit } from '@angular/core' 3import { SelectOptionsItem } from 'src/types/select-options-item.model'
4import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
4import { FormGroup } from '@angular/forms' 5import { FormGroup } from '@angular/forms'
6import { MenuService } from '@app/core'
5import { ServerConfig } from '@shared/models' 7import { ServerConfig } from '@shared/models'
6import { ConfigService } from '../shared/config.service' 8import { ConfigService } from '../shared/config.service'
7 9
@@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service'
10 templateUrl: './edit-basic-configuration.component.html', 12 templateUrl: './edit-basic-configuration.component.html',
11 styleUrls: [ './edit-custom-config.component.scss' ] 13 styleUrls: [ './edit-custom-config.component.scss' ]
12}) 14})
13export class EditBasicConfigurationComponent implements OnInit { 15export class EditBasicConfigurationComponent implements OnInit, OnChanges {
14 @Input() form: FormGroup 16 @Input() form: FormGroup
15 @Input() formErrors: any 17 @Input() formErrors: any
16 18
17 @Input() serverConfig: ServerConfig 19 @Input() serverConfig: ServerConfig
18 20
19 signupAlertMessage: string 21 signupAlertMessage: string
22 defaultLandingPageOptions: SelectOptionsItem[] = []
20 23
21 constructor ( 24 constructor (
22 private configService: ConfigService 25 private configService: ConfigService,
26 private menuService: MenuService
23 ) { } 27 ) { }
24 28
25 ngOnInit () { 29 ngOnInit () {
30 this.buildLandingPageOptions()
26 this.checkSignupField() 31 this.checkSignupField()
27 } 32 }
28 33
34 ngOnChanges (changes: SimpleChanges) {
35 if (changes['serverConfig']) {
36 this.buildLandingPageOptions()
37 }
38 }
39
29 getVideoQuotaOptions () { 40 getVideoQuotaOptions () {
30 return this.configService.videoQuotaOptions 41 return this.configService.videoQuotaOptions
31 } 42 }
@@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit {
70 return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true 81 return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
71 } 82 }
72 83
84 buildLandingPageOptions () {
85 this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig)
86 .map(o => ({
87 id: o.path,
88 label: o.label,
89 description: o.path
90 }))
91 }
92
73 private checkSignupField () { 93 private checkSignupField () {
74 const signupControl = this.form.get('signup.enabled') 94 const signupControl = this.form.get('signup.enabled')
75 95
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index b6365614d..3ceea02ca 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -3,8 +3,16 @@
3 3
4 <div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs"> 4 <div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
5 5
6 <ng-container ngbNavItem="instance-homepage">
7 <a ngbNavLink i18n>Homepage</a>
8
9 <ng-template ngbNavContent>
10 <my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
11 </ng-template>
12 </ng-container>
13
6 <ng-container ngbNavItem="instance-information"> 14 <ng-container ngbNavItem="instance-information">
7 <a ngbNavLink i18n>Instance information</a> 15 <a ngbNavLink i18n>Information</a>
8 16
9 <ng-template ngbNavContent> 17 <ng-template ngbNavContent>
10 <my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems"> 18 <my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
@@ -13,7 +21,7 @@
13 </ng-container> 21 </ng-container>
14 22
15 <ng-container ngbNavItem="basic-configuration"> 23 <ng-container ngbNavItem="basic-configuration">
16 <a ngbNavLink i18n>Basic configuration</a> 24 <a ngbNavLink i18n>Basic</a>
17 25
18 <ng-template ngbNavContent> 26 <ng-template ngbNavContent>
19 <my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig"> 27 <my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
@@ -40,7 +48,7 @@
40 </ng-container> 48 </ng-container>
41 49
42 <ng-container ngbNavItem="advanced-configuration"> 50 <ng-container ngbNavItem="advanced-configuration">
43 <a ngbNavLink i18n>Advanced configuration</a> 51 <a ngbNavLink i18n>Advanced</a>
44 52
45 <ng-template ngbNavContent> 53 <ng-template ngbNavContent>
46 <my-edit-advanced-configuration [form]="form" [formErrors]="formErrors"> 54 <my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 4b35d65fc..dc8334dd0 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -1,4 +1,5 @@
1 1
2import omit from 'lodash-es/omit'
2import { forkJoin } from 'rxjs' 3import { forkJoin } from 'rxjs'
3import { SelectOptionsItem } from 'src/types/select-options-item.model' 4import { SelectOptionsItem } from 'src/types/select-options-item.model'
4import { Component, OnInit } from '@angular/core' 5import { Component, OnInit } from '@angular/core'
@@ -24,9 +25,14 @@ import {
24} from '@app/shared/form-validators/custom-config-validators' 25} from '@app/shared/form-validators/custom-config-validators'
25import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' 26import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
26import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 27import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
27import { CustomConfig, ServerConfig } from '@shared/models' 28import { CustomPageService } from '@app/shared/shared-main/custom-page'
29import { CustomConfig, CustomPage, ServerConfig } from '@shared/models'
28import { EditConfigurationService } from './edit-configuration.service' 30import { EditConfigurationService } from './edit-configuration.service'
29 31
32type ComponentCustomConfig = CustomConfig & {
33 instanceCustomHomepage: CustomPage
34}
35
30@Component({ 36@Component({
31 selector: 'my-edit-custom-config', 37 selector: 'my-edit-custom-config',
32 templateUrl: './edit-custom-config.component.html', 38 templateUrl: './edit-custom-config.component.html',
@@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service'
35export class EditCustomConfigComponent extends FormReactive implements OnInit { 41export class EditCustomConfigComponent extends FormReactive implements OnInit {
36 activeNav: string 42 activeNav: string
37 43
38 customConfig: CustomConfig 44 customConfig: ComponentCustomConfig
39 serverConfig: ServerConfig 45 serverConfig: ServerConfig
40 46
47 homepage: CustomPage
48
41 languageItems: SelectOptionsItem[] = [] 49 languageItems: SelectOptionsItem[] = []
42 categoryItems: SelectOptionsItem[] = [] 50 categoryItems: SelectOptionsItem[] = []
43 51
@@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
47 protected formValidatorService: FormValidatorService, 55 protected formValidatorService: FormValidatorService,
48 private notifier: Notifier, 56 private notifier: Notifier,
49 private configService: ConfigService, 57 private configService: ConfigService,
58 private customPage: CustomPageService,
50 private serverService: ServerService, 59 private serverService: ServerService,
51 private editConfigurationService: EditConfigurationService 60 private editConfigurationService: EditConfigurationService
52 ) { 61 ) {
@@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
56 ngOnInit () { 65 ngOnInit () {
57 this.serverConfig = this.serverService.getTmpConfig() 66 this.serverConfig = this.serverService.getTmpConfig()
58 this.serverService.getConfig() 67 this.serverService.getConfig()
59 .subscribe(config => { 68 .subscribe(config => this.serverConfig = config)
60 this.serverConfig = config
61 })
62 69
63 const formGroupData: { [key in keyof CustomConfig ]: any } = { 70 const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
64 instance: { 71 instance: {
65 name: INSTANCE_NAME_VALIDATOR, 72 name: INSTANCE_NAME_VALIDATOR,
66 shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR, 73 shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
@@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
215 disableLocalSearch: null, 222 disableLocalSearch: null,
216 isDefaultSearch: null 223 isDefaultSearch: null
217 } 224 }
225 },
226
227 instanceCustomHomepage: {
228 content: null
218 } 229 }
219 } 230 }
220 231
@@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
250 } 261 }
251 262
252 async formValidated () { 263 async formValidated () {
253 const value: CustomConfig = this.form.getRawValue() 264 const value: ComponentCustomConfig = this.form.getRawValue()
254 265
255 this.configService.updateCustomConfig(value) 266 forkJoin([
267 this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
268 this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
269 ])
256 .subscribe( 270 .subscribe(
257 res => { 271 ([ resConfig ]) => {
258 this.customConfig = res 272 const instanceCustomHomepage = {
273 content: value.instanceCustomHomepage.content
274 }
275
276 this.customConfig = { ...resConfig, instanceCustomHomepage }
259 277
260 // Reload general configuration 278 // Reload general configuration
261 this.serverService.resetConfig() 279 this.serverService.resetConfig()
280 .subscribe(config => this.serverConfig = config)
262 281
263 this.updateForm() 282 this.updateForm()
264 283
@@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
317 } 336 }
318 337
319 private loadConfigAndUpdateForm () { 338 private loadConfigAndUpdateForm () {
320 this.configService.getCustomConfig() 339 forkJoin([
321 .subscribe(config => { 340 this.configService.getCustomConfig(),
322 this.customConfig = config 341 this.customPage.getInstanceHomepage()
342 ])
343 .subscribe(([ config, homepage ]) => {
344 this.customConfig = { ...config, instanceCustomHomepage: homepage }
323 345
324 this.updateForm() 346 this.updateForm()
325 // Force form validation 347 // 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
index 000000000..c48fa5bf8
--- /dev/null
+++ b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
@@ -0,0 +1,28 @@
1<ng-container [formGroup]="form">
2
3 <ng-container formGroupName="instanceCustomHomepage">
4
5 <div class="form-row mt-5"> <!-- homepage grid -->
6 <div class="form-group col-12 col-lg-4 col-xl-3">
7 <div i18n class="inner-form-title">INSTANCE HOMEPAGE</div>
8 </div>
9
10 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
11
12 <div class="form-group">
13 <label i18n for="instanceCustomHomepageContent">Homepage</label>
14
15 <my-markdown-textarea
16 name="instanceCustomHomepageContent" formControlName="content" textareaMaxWidth="90%" textareaHeight="300px"
17 [customMarkdownRenderer]="customMarkdownRenderer"
18 [classes]="{ 'input-error': formErrors['instanceCustomHomepage.content'] }"
19 ></my-markdown-textarea>
20
21 <div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error">{{ formErrors.instanceCustomHomepage.content }}</div>
22 </div>
23 </div>
24 </div>
25
26 </ng-container>
27
28</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
index 000000000..7decf8f75
--- /dev/null
+++ b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts
@@ -0,0 +1,25 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { FormGroup } from '@angular/forms'
3import { CustomMarkupService } from '@app/shared/shared-custom-markup'
4
5@Component({
6 selector: 'my-edit-homepage',
7 templateUrl: './edit-homepage.component.html',
8 styleUrls: [ './edit-custom-config.component.scss' ]
9})
10export class EditHomepageComponent implements OnInit {
11 @Input() form: FormGroup
12 @Input() formErrors: any
13
14 customMarkdownRenderer: (text: string) => Promise<HTMLElement>
15
16 constructor (private customMarkup: CustomMarkupService) {
17
18 }
19
20 ngOnInit () {
21 this.customMarkdownRenderer = async (text: string) => {
22 return this.customMarkup.buildElement(text)
23 }
24 }
25}
diff --git a/client/src/app/+admin/config/edit-custom-config/index.ts b/client/src/app/+admin/config/edit-custom-config/index.ts
index 95fcc8f52..4281ad09b 100644
--- a/client/src/app/+admin/config/edit-custom-config/index.ts
+++ b/client/src/app/+admin/config/edit-custom-config/index.ts
@@ -2,6 +2,7 @@ export * from './edit-advanced-configuration.component'
2export * from './edit-basic-configuration.component' 2export * from './edit-basic-configuration.component'
3export * from './edit-configuration.service' 3export * from './edit-configuration.service'
4export * from './edit-custom-config.component' 4export * from './edit-custom-config.component'
5export * from './edit-homepage.component'
5export * from './edit-instance-information.component' 6export * from './edit-instance-information.component'
6export * from './edit-live-configuration.component' 7export * from './edit-live-configuration.component'
7export * from './edit-vod-transcoding.component' 8export * 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
index 000000000..1eaee4449
--- /dev/null
+++ b/client/src/app/+home/home-routing.module.ts
@@ -0,0 +1,18 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { HomeComponent } from './home.component'
5
6const homeRoutes: Routes = [
7 {
8 path: '',
9 component: HomeComponent,
10 canActivateChild: [ MetaGuard ]
11 }
12]
13
14@NgModule({
15 imports: [ RouterModule.forChild(homeRoutes) ],
16 exports: [ RouterModule ]
17})
18export class HomeRoutingModule {}
diff --git a/client/src/app/+home/home.component.html b/client/src/app/+home/home.component.html
new file mode 100644
index 000000000..645b9dc69
--- /dev/null
+++ b/client/src/app/+home/home.component.html
@@ -0,0 +1,4 @@
1<div class="root margin-content">
2 <div #contentWrapper></div>
3</div>
4
diff --git a/client/src/app/+home/home.component.scss b/client/src/app/+home/home.component.scss
new file mode 100644
index 000000000..6c73e9248
--- /dev/null
+++ b/client/src/app/+home/home.component.scss
@@ -0,0 +1,3 @@
1.root {
2 padding-top: 20px;
3}
diff --git a/client/src/app/+home/home.component.ts b/client/src/app/+home/home.component.ts
new file mode 100644
index 000000000..16d3a6df7
--- /dev/null
+++ b/client/src/app/+home/home.component.ts
@@ -0,0 +1,26 @@
1
2import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
3import { CustomMarkupService } from '@app/shared/shared-custom-markup'
4import { CustomPageService } from '@app/shared/shared-main/custom-page'
5
6@Component({
7 templateUrl: './home.component.html',
8 styleUrls: [ './home.component.scss' ]
9})
10
11export class HomeComponent implements OnInit {
12 @ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement>
13
14 constructor (
15 private customMarkupService: CustomMarkupService,
16 private customPageService: CustomPageService
17 ) { }
18
19 async ngOnInit () {
20 this.customPageService.getInstanceHomepage()
21 .subscribe(async ({ content }) => {
22 const element = await this.customMarkupService.buildElement(content)
23 this.contentWrapper.nativeElement.appendChild(element)
24 })
25 }
26}
diff --git a/client/src/app/+home/home.module.ts b/client/src/app/+home/home.module.ts
new file mode 100644
index 000000000..102cdc296
--- /dev/null
+++ b/client/src/app/+home/home.module.ts
@@ -0,0 +1,25 @@
1import { NgModule } from '@angular/core'
2import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
3import { SharedMainModule } from '@app/shared/shared-main'
4import { HomeRoutingModule } from './home-routing.module'
5import { HomeComponent } from './home.component'
6
7@NgModule({
8 imports: [
9 HomeRoutingModule,
10
11 SharedMainModule,
12 SharedCustomMarkupModule
13 ],
14
15 declarations: [
16 HomeComponent
17 ],
18
19 exports: [
20 HomeComponent
21 ],
22
23 providers: [ ]
24})
25export class HomeModule { }
diff --git a/client/src/app/+home/index.ts b/client/src/app/+home/index.ts
new file mode 100644
index 000000000..7c77cf9fd
--- /dev/null
+++ b/client/src/app/+home/index.ts
@@ -0,0 +1,3 @@
1export * from './home-routing.module'
2export * from './home.component'
3export * from './home.module'
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
index fd379e80e..04f8f0d58 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
@@ -161,7 +161,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
161 // Before HTML rendering restore line feed for markdown list compatibility 161 // Before HTML rendering restore line feed for markdown list compatibility
162 const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n') 162 const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
163 const html = await this.markdownService.textMarkdownToHTML(commentText, true, true) 163 const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
164 this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) 164 this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html)
165 this.newParentComments = this.parentComments.concat([ this.comment ]) 165 this.newParentComments = this.parentComments.concat([ this.comment ])
166 166
167 if (this.comment.account) { 167 if (this.comment.account) {
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 116139d47..77405d149 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -509,7 +509,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
509 509
510 private async setVideoDescriptionHTML () { 510 private async setVideoDescriptionHTML () {
511 const html = await this.markdownService.textMarkdownToHTML(this.video.description) 511 const html = await this.markdownService.textMarkdownToHTML(this.video.description)
512 this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html) 512 this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html)
513 } 513 }
514 514
515 private setVideoLikesBarTooltipText () { 515 private setVideoLikesBarTooltipText () {
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index 3ea5b7e5e..57e485e8e 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -14,6 +14,10 @@ const routes: Routes = [
14 loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule) 14 loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
15 }, 15 },
16 { 16 {
17 path: 'home',
18 loadChildren: () => import('./+home/home.module').then(m => m.HomeModule)
19 },
20 {
17 path: 'my-account', 21 path: 'my-account',
18 loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule) 22 loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule)
19 }, 23 },
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 239e275a4..863c3f3b5 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -231,7 +231,7 @@ export class AppComponent implements OnInit, AfterViewInit {
231 } 231 }
232 232
233 this.broadcastMessage = { 233 this.broadcastMessage = {
234 message: await this.markdownService.completeMarkdownToHTML(messageConfig.message), 234 message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true),
235 dismissable: messageConfig.dismissable, 235 dismissable: messageConfig.dismissable,
236 class: classes[messageConfig.level] 236 class: classes[messageConfig.level]
237 } 237 }
diff --git a/client/src/app/core/menu/menu.service.ts b/client/src/app/core/menu/menu.service.ts
index 502d3bb2f..77592cbb6 100644
--- a/client/src/app/core/menu/menu.service.ts
+++ b/client/src/app/core/menu/menu.service.ts
@@ -1,8 +1,19 @@
1import { fromEvent } from 'rxjs' 1import { fromEvent } from 'rxjs'
2import { debounceTime } from 'rxjs/operators' 2import { debounceTime } from 'rxjs/operators'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { GlobalIconName } from '@app/shared/shared-icons'
5import { sortObjectComparator } from '@shared/core-utils/miscs/miscs'
6import { ServerConfig } from '@shared/models/server'
4import { ScreenService } from '../wrappers' 7import { ScreenService } from '../wrappers'
5 8
9export type MenuLink = {
10 icon: GlobalIconName
11 label: string
12 menuLabel: string
13 path: string
14 priority: number
15}
16
6@Injectable() 17@Injectable()
7export class MenuService { 18export class MenuService {
8 isMenuDisplayed = true 19 isMenuDisplayed = true
@@ -48,6 +59,53 @@ export class MenuService {
48 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser 59 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
49 } 60 }
50 61
62 buildCommonLinks (config: ServerConfig) {
63 let entries: MenuLink[] = [
64 {
65 icon: 'globe' as 'globe',
66 label: $localize`Discover videos`,
67 menuLabel: $localize`Discover`,
68 path: '/videos/overview',
69 priority: 150
70 },
71 {
72 icon: 'trending' as 'trending',
73 label: $localize`Trending videos`,
74 menuLabel: $localize`Trending`,
75 path: '/videos/trending',
76 priority: 140
77 },
78 {
79 icon: 'recently-added' as 'recently-added',
80 label: $localize`Recently added videos`,
81 menuLabel: $localize`Recently added`,
82 path: '/videos/recently-added',
83 priority: 130
84 },
85 {
86 icon: 'octagon' as 'octagon',
87 label: $localize`Local videos`,
88 menuLabel: $localize`Local videos`,
89 path: '/videos/local',
90 priority: 120
91 }
92 ]
93
94 if (config.homepage.enabled) {
95 entries.push({
96 icon: 'home' as 'home',
97 label: $localize`Home`,
98 menuLabel: $localize`Home`,
99 path: '/home',
100 priority: 160
101 })
102 }
103
104 entries = entries.sort(sortObjectComparator('priority', 'desc'))
105
106 return entries
107 }
108
51 private handleWindowResize () { 109 private handleWindowResize () {
52 // On touch screens, do not handle window resize event since opened menu is handled with a content overlay 110 // On touch screens, do not handle window resize event since opened menu is handled with a content overlay
53 if (this.screenService.isInTouchScreen()) return 111 if (this.screenService.isInTouchScreen()) return
diff --git a/client/src/app/core/renderer/html-renderer.service.ts b/client/src/app/core/renderer/html-renderer.service.ts
index 3176cf6a4..418d8603e 100644
--- a/client/src/app/core/renderer/html-renderer.service.ts
+++ b/client/src/app/core/renderer/html-renderer.service.ts
@@ -1,6 +1,6 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { LinkifierService } from './linkifier.service' 2import { LinkifierService } from './linkifier.service'
3import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html' 3import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html'
4 4
5@Injectable() 5@Injectable()
6export class HtmlRendererService { 6export class HtmlRendererService {
@@ -20,7 +20,7 @@ export class HtmlRendererService {
20 }) 20 })
21 } 21 }
22 22
23 async toSafeHtml (text: string) { 23 async toSafeHtml (text: string, additionalAllowedTags: string[] = []) {
24 const [ html ] = await Promise.all([ 24 const [ html ] = await Promise.all([
25 // Convert possible markdown to html 25 // Convert possible markdown to html
26 this.linkifier.linkify(text), 26 this.linkifier.linkify(text),
@@ -28,7 +28,11 @@ export class HtmlRendererService {
28 this.loadSanitizeHtml() 28 this.loadSanitizeHtml()
29 ]) 29 ])
30 30
31 return this.sanitizeHtml(html, SANITIZE_OPTIONS) 31 const options = additionalAllowedTags.length !== 0
32 ? getCustomMarkupSanitizeOptions(additionalAllowedTags)
33 : getSanitizeOptions()
34
35 return this.sanitizeHtml(html, options)
32 } 36 }
33 37
34 private async loadSanitizeHtml () { 38 private async loadSanitizeHtml () {
diff --git a/client/src/app/core/renderer/markdown.service.ts b/client/src/app/core/renderer/markdown.service.ts
index edddb0a66..ca1bf4eb9 100644
--- a/client/src/app/core/renderer/markdown.service.ts
+++ b/client/src/app/core/renderer/markdown.service.ts
@@ -17,12 +17,15 @@ type MarkdownParsers = {
17 enhancedMarkdownIt: MarkdownIt 17 enhancedMarkdownIt: MarkdownIt
18 enhancedWithHTMLMarkdownIt: MarkdownIt 18 enhancedWithHTMLMarkdownIt: MarkdownIt
19 19
20 completeMarkdownIt: MarkdownIt 20 unsafeMarkdownIt: MarkdownIt
21
22 customPageMarkdownIt: MarkdownIt
21} 23}
22 24
23type MarkdownConfig = { 25type MarkdownConfig = {
24 rules: string[] 26 rules: string[]
25 html: boolean 27 html: boolean
28 breaks: boolean
26 escape?: boolean 29 escape?: boolean
27} 30}
28 31
@@ -35,18 +38,24 @@ export class MarkdownService {
35 private markdownParsers: MarkdownParsers = { 38 private markdownParsers: MarkdownParsers = {
36 textMarkdownIt: null, 39 textMarkdownIt: null,
37 textWithHTMLMarkdownIt: null, 40 textWithHTMLMarkdownIt: null,
41
38 enhancedMarkdownIt: null, 42 enhancedMarkdownIt: null,
39 enhancedWithHTMLMarkdownIt: null, 43 enhancedWithHTMLMarkdownIt: null,
40 completeMarkdownIt: null 44
45 unsafeMarkdownIt: null,
46
47 customPageMarkdownIt: null
41 } 48 }
42 private parsersConfig: MarkdownParserConfigs = { 49 private parsersConfig: MarkdownParserConfigs = {
43 textMarkdownIt: { rules: TEXT_RULES, html: false }, 50 textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false },
44 textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true }, 51 textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true },
45 52
46 enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false }, 53 enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false },
47 enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true }, 54 enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, breaks: true, html: true, escape: true },
48 55
49 completeMarkdownIt: { rules: COMPLETE_RULES, html: true } 56 unsafeMarkdownIt: { rules: COMPLETE_RULES, breaks: true, html: true, escape: false },
57
58 customPageMarkdownIt: { rules: COMPLETE_RULES, breaks: false, html: true, escape: true }
50 } 59 }
51 60
52 private emojiModule: any 61 private emojiModule: any
@@ -54,22 +63,26 @@ export class MarkdownService {
54 constructor (private htmlRenderer: HtmlRendererService) {} 63 constructor (private htmlRenderer: HtmlRendererService) {}
55 64
56 textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) { 65 textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
57 if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown, withEmoji) 66 if (withHtml) return this.render({ name: 'textWithHTMLMarkdownIt', markdown, withEmoji })
58 67
59 return this.render('textMarkdownIt', markdown, withEmoji) 68 return this.render({ name: 'textMarkdownIt', markdown, withEmoji })
60 } 69 }
61 70
62 enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) { 71 enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
63 if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown, withEmoji) 72 if (withHtml) return this.render({ name: 'enhancedWithHTMLMarkdownIt', markdown, withEmoji })
73
74 return this.render({ name: 'enhancedMarkdownIt', markdown, withEmoji })
75 }
64 76
65 return this.render('enhancedMarkdownIt', markdown, withEmoji) 77 unsafeMarkdownToHTML (markdown: string, _trustedInput: true) {
78 return this.render({ name: 'unsafeMarkdownIt', markdown, withEmoji: true })
66 } 79 }
67 80
68 completeMarkdownToHTML (markdown: string) { 81 customPageMarkdownToHTML (markdown: string, additionalAllowedTags: string[]) {
69 return this.render('completeMarkdownIt', markdown, true) 82 return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
70 } 83 }
71 84
72 async processVideoTimestamps (html: string) { 85 processVideoTimestamps (html: string) {
73 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { 86 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
74 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) 87 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
75 const url = buildVideoLink({ startTime: t }) 88 const url = buildVideoLink({ startTime: t })
@@ -77,7 +90,13 @@ export class MarkdownService {
77 }) 90 })
78 } 91 }
79 92
80 private async render (name: keyof MarkdownParsers, markdown: string, withEmoji = false) { 93 private async render (options: {
94 name: keyof MarkdownParsers
95 markdown: string
96 withEmoji: boolean
97 additionalAllowedTags?: string[]
98 }) {
99 const { name, markdown, withEmoji, additionalAllowedTags } = options
81 if (!markdown) return '' 100 if (!markdown) return ''
82 101
83 const config = this.parsersConfig[ name ] 102 const config = this.parsersConfig[ name ]
@@ -96,7 +115,7 @@ export class MarkdownService {
96 let html = this.markdownParsers[ name ].render(markdown) 115 let html = this.markdownParsers[ name ].render(markdown)
97 html = this.avoidTruncatedTags(html) 116 html = this.avoidTruncatedTags(html)
98 117
99 if (config.escape) return this.htmlRenderer.toSafeHtml(html) 118 if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags)
100 119
101 return html 120 return html
102 } 121 }
@@ -105,7 +124,7 @@ export class MarkdownService {
105 // FIXME: import('...') returns a struct module, containing a "default" field 124 // FIXME: import('...') returns a struct module, containing a "default" field
106 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default 125 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
107 126
108 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) 127 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: config.breaks, html: config.html })
109 128
110 for (const rule of config.rules) { 129 for (const rule of config.rules) {
111 markdownIt.enable(rule) 130 markdownIt.enable(rule)
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index e48786e18..5b1b7603f 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -173,6 +173,9 @@ export class ServerService {
173 disableLocalSearch: false, 173 disableLocalSearch: false,
174 isDefaultSearch: false 174 isDefaultSearch: false
175 } 175 }
176 },
177 homepage: {
178 enabled: false
176 } 179 }
177 } 180 }
178 181
@@ -198,9 +201,7 @@ export class ServerService {
198 this.configReset = true 201 this.configReset = true
199 202
200 // Notify config update 203 // Notify config update
201 this.getConfig().subscribe(() => { 204 return this.getConfig()
202 // empty, to fire a reset config event
203 })
204 } 205 }
205 206
206 getConfig () { 207 getConfig () {
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index 2e07deca2..fcc0bc21a 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -123,24 +123,9 @@
123 <div class="on-instance"> 123 <div class="on-instance">
124 <div i18n class="block-title">ON {{instanceName}}</div> 124 <div i18n class="block-title">ON {{instanceName}}</div>
125 125
126 <a class="menu-link" routerLink="/videos/overview" routerLinkActive="active"> 126 <a class="menu-link" *ngFor="let commonLink of commonMenuLinks" [routerLink]="commonLink.path" routerLinkActive="active">
127 <my-global-icon iconName="globe" aria-hidden="true"></my-global-icon> 127 <my-global-icon [iconName]="commonLink.icon" aria-hidden="true"></my-global-icon>
128 <ng-container i18n>Discover</ng-container> 128 <ng-container>{{ commonLink.menuLabel }}</ng-container>
129 </a>
130
131 <a class="menu-link" routerLink="/videos/trending" routerLinkActive="active">
132 <my-global-icon iconName="trending" aria-hidden="true"></my-global-icon>
133 <ng-container i18n>Trending</ng-container>
134 </a>
135
136 <a class="menu-link" routerLink="/videos/recently-added" routerLinkActive="active">
137 <my-global-icon iconName="recently-added" aria-hidden="true"></my-global-icon>
138 <ng-container i18n>Recently added</ng-container>
139 </a>
140
141 <a class="menu-link" routerLink="/videos/local" routerLinkActive="active">
142 <my-global-icon iconName="home" aria-hidden="true"></my-global-icon>
143 <ng-container i18n>Local videos</ng-container>
144 </a> 129 </a>
145 </div> 130 </div>
146 </div> 131 </div>
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 8fa1de326..2f7e0cf07 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators'
4import { ViewportScroller } from '@angular/common' 4import { ViewportScroller } from '@angular/common'
5import { Component, OnInit, ViewChild } from '@angular/core' 5import { Component, OnInit, ViewChild } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core' 7import {
8 AuthService,
9 AuthStatus,
10 AuthUser,
11 MenuLink,
12 MenuService,
13 RedirectService,
14 ScreenService,
15 ServerService,
16 UserService
17} from '@app/core'
8import { scrollToTop } from '@app/helpers' 18import { scrollToTop } from '@app/helpers'
9import { LanguageChooserComponent } from '@app/menu/language-chooser.component' 19import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
10import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' 20import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
@@ -35,6 +45,8 @@ export class MenuComponent implements OnInit {
35 45
36 currentInterfaceLanguage: string 46 currentInterfaceLanguage: string
37 47
48 commonMenuLinks: MenuLink[] = []
49
38 private languages: VideoConstant<string>[] = [] 50 private languages: VideoConstant<string>[] = []
39 private serverConfig: ServerConfig 51 private serverConfig: ServerConfig
40 private routesPerRight: { [role in UserRight]?: string } = { 52 private routesPerRight: { [role in UserRight]?: string } = {
@@ -80,7 +92,10 @@ export class MenuComponent implements OnInit {
80 ngOnInit () { 92 ngOnInit () {
81 this.serverConfig = this.serverService.getTmpConfig() 93 this.serverConfig = this.serverService.getTmpConfig()
82 this.serverService.getConfig() 94 this.serverService.getConfig()
83 .subscribe(config => this.serverConfig = config) 95 .subscribe(config => {
96 this.serverConfig = config
97 this.buildMenuLinks()
98 })
84 99
85 this.isLoggedIn = this.authService.isLoggedIn() 100 this.isLoggedIn = this.authService.isLoggedIn()
86 if (this.isLoggedIn === true) { 101 if (this.isLoggedIn === true) {
@@ -241,6 +256,10 @@ export class MenuComponent implements OnInit {
241 } 256 }
242 } 257 }
243 258
259 private buildMenuLinks () {
260 this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig)
261 }
262
244 private buildUserLanguages () { 263 private buildUserLanguages () {
245 if (!this.user) { 264 if (!this.user) {
246 this.videoLanguages = [] 265 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
index 000000000..da81006b9
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html
@@ -0,0 +1,8 @@
1<div *ngIf="channel" class="channel">
2 <my-actor-avatar [channel]="channel" size="34"></my-actor-avatar>
3
4 <div class="display-name">{{ channel.displayName }}</div>
5 <div class="username">{{ channel.name }}</div>
6
7 <div class="description">{{ channel.description }}</div>
8</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
index 000000000..85018afe2
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss
@@ -0,0 +1,9 @@
1@import '_variables';
2@import '_mixins';
3
4.channel {
5 border-radius: 15px;
6 padding: 10px;
7 width: min-content;
8 border: 1px solid pvar(--mainColor);
9}
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
index 000000000..97bb5567e
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts
@@ -0,0 +1,26 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { VideoChannel, VideoChannelService } from '../shared-main'
3
4/*
5 * Markup component that creates a channel miniature only
6*/
7
8@Component({
9 selector: 'my-channel-miniature-markup',
10 templateUrl: 'channel-miniature-markup.component.html',
11 styleUrls: [ 'channel-miniature-markup.component.scss' ]
12})
13export class ChannelMiniatureMarkupComponent implements OnInit {
14 @Input() name: string
15
16 channel: VideoChannel
17
18 constructor (
19 private channelService: VideoChannelService
20 ) { }
21
22 ngOnInit () {
23 this.channelService.getVideoChannel(this.name)
24 .subscribe(channel => this.channel = channel)
25 }
26}
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
index 000000000..ffaf15710
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts
@@ -0,0 +1,136 @@
1import { ComponentRef, Injectable } from '@angular/core'
2import { MarkdownService } from '@app/core'
3import {
4 ChannelMiniatureMarkupData,
5 EmbedMarkupData,
6 PlaylistMiniatureMarkupData,
7 VideoMiniatureMarkupData,
8 VideosListMarkupData
9} from '@shared/models'
10import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
11import { DynamicElementService } from './dynamic-element.service'
12import { EmbedMarkupComponent } from './embed-markup.component'
13import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
14import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
15import { VideosListMarkupComponent } from './videos-list-markup.component'
16
17type BuilderFunction = (el: HTMLElement) => ComponentRef<any>
18
19@Injectable()
20export class CustomMarkupService {
21 private builders: { [ selector: string ]: BuilderFunction } = {
22 'peertube-video-embed': el => this.embedBuilder(el, 'video'),
23 'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'),
24 'peertube-video-miniature': el => this.videoMiniatureBuilder(el),
25 'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el),
26 'peertube-channel-miniature': el => this.channelMiniatureBuilder(el),
27 'peertube-videos-list': el => this.videosListBuilder(el)
28 }
29
30 constructor (
31 private dynamicElementService: DynamicElementService,
32 private markdown: MarkdownService
33 ) { }
34
35 async buildElement (text: string) {
36 const html = await this.markdown.customPageMarkdownToHTML(text, this.getSupportedTags())
37
38 const rootElement = document.createElement('div')
39 rootElement.innerHTML = html
40
41 for (const selector of this.getSupportedTags()) {
42 rootElement.querySelectorAll(selector)
43 .forEach((e: HTMLElement) => {
44 try {
45 const component = this.execBuilder(selector, e)
46
47 this.dynamicElementService.injectElement(e, component)
48 } catch (err) {
49 console.error('Cannot inject component %s.', selector, err)
50 }
51 })
52 }
53
54 return rootElement
55 }
56
57 private getSupportedTags () {
58 return Object.keys(this.builders)
59 }
60
61 private execBuilder (selector: string, el: HTMLElement) {
62 return this.builders[selector](el)
63 }
64
65 private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') {
66 const data = el.dataset as EmbedMarkupData
67 const component = this.dynamicElementService.createElement(EmbedMarkupComponent)
68
69 this.dynamicElementService.setModel(component, { uuid: data.uuid, type })
70
71 return component
72 }
73
74 private videoMiniatureBuilder (el: HTMLElement) {
75 const data = el.dataset as VideoMiniatureMarkupData
76 const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent)
77
78 this.dynamicElementService.setModel(component, { uuid: data.uuid })
79
80 return component
81 }
82
83 private playlistMiniatureBuilder (el: HTMLElement) {
84 const data = el.dataset as PlaylistMiniatureMarkupData
85 const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent)
86
87 this.dynamicElementService.setModel(component, { uuid: data.uuid })
88
89 return component
90 }
91
92 private channelMiniatureBuilder (el: HTMLElement) {
93 const data = el.dataset as ChannelMiniatureMarkupData
94 const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent)
95
96 this.dynamicElementService.setModel(component, { name: data.name })
97
98 return component
99 }
100
101 private videosListBuilder (el: HTMLElement) {
102 const data = el.dataset as VideosListMarkupData
103 const component = this.dynamicElementService.createElement(VideosListMarkupComponent)
104
105 const model = {
106 title: data.title,
107 description: data.description,
108 sort: data.sort,
109 categoryOneOf: this.buildArrayNumber(data.categoryOneOf),
110 languageOneOf: this.buildArrayString(data.languageOneOf),
111 count: this.buildNumber(data.count) || 10
112 }
113
114 this.dynamicElementService.setModel(component, model)
115
116 return component
117 }
118
119 private buildNumber (value: string) {
120 if (!value) return undefined
121
122 return parseInt(value, 10)
123 }
124
125 private buildArrayNumber (value: string) {
126 if (!value) return undefined
127
128 return value.split(',').map(v => parseInt(v, 10))
129 }
130
131 private buildArrayString (value: string) {
132 if (!value) return undefined
133
134 return value.split(',')
135 }
136}
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
index 000000000..e967e30ac
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts
@@ -0,0 +1,57 @@
1import {
2 ApplicationRef,
3 ComponentFactoryResolver,
4 ComponentRef,
5 EmbeddedViewRef,
6 Injectable,
7 Injector,
8 OnChanges,
9 SimpleChange,
10 SimpleChanges,
11 Type
12} from '@angular/core'
13
14@Injectable()
15export class DynamicElementService {
16
17 constructor (
18 private injector: Injector,
19 private applicationRef: ApplicationRef,
20 private componentFactoryResolver: ComponentFactoryResolver
21 ) { }
22
23 createElement <T> (ofComponent: Type<T>) {
24 const div = document.createElement('div')
25
26 const component = this.componentFactoryResolver.resolveComponentFactory(ofComponent)
27 .create(this.injector, [], div)
28
29 return component
30 }
31
32 injectElement <T> (wrapper: HTMLElement, componentRef: ComponentRef<T>) {
33 const hostView = componentRef.hostView as EmbeddedViewRef<any>
34
35 this.applicationRef.attachView(hostView)
36 wrapper.appendChild(hostView.rootNodes[0])
37 }
38
39 setModel <T> (componentRef: ComponentRef<T>, attributes: Partial<T>) {
40 const changes: SimpleChanges = {}
41
42 for (const key of Object.keys(attributes)) {
43 const previousValue = componentRef.instance[key]
44 const newValue = attributes[key]
45
46 componentRef.instance[key] = newValue
47 changes[key] = new SimpleChange(previousValue, newValue, previousValue === undefined)
48 }
49
50 const component = componentRef.instance
51 if (typeof (component as unknown as OnChanges).ngOnChanges === 'function') {
52 (component as unknown as OnChanges).ngOnChanges(changes)
53 }
54
55 componentRef.changeDetectorRef.detectChanges()
56 }
57}
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
index 000000000..a854d89f6
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/embed-markup.component.ts
@@ -0,0 +1,22 @@
1import { buildPlaylistLink, buildVideoLink, buildVideoOrPlaylistEmbed } from 'src/assets/player/utils'
2import { environment } from 'src/environments/environment'
3import { Component, ElementRef, Input, OnInit } from '@angular/core'
4
5@Component({
6 selector: 'my-embed-markup',
7 template: ''
8})
9export class EmbedMarkupComponent implements OnInit {
10 @Input() uuid: string
11 @Input() type: 'video' | 'playlist' = 'video'
12
13 constructor (private el: ElementRef) { }
14
15 ngOnInit () {
16 const link = this.type === 'video'
17 ? buildVideoLink({ baseUrl: `${environment.originServerUrl}/videos/embed/${this.uuid}` })
18 : buildPlaylistLink({ baseUrl: `${environment.originServerUrl}/video-playlists/embed/${this.uuid}` })
19
20 this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed(link, this.uuid)
21 }
22}
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
index 000000000..14bde3ea9
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/index.ts
@@ -0,0 +1,3 @@
1export * from './custom-markup.service'
2export * from './dynamic-element.service'
3export * 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
index 000000000..4e1d1a13f
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html
@@ -0,0 +1,2 @@
1<my-video-playlist-miniature *ngIf="playlist" [playlist]="playlist">
2</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
index 000000000..281cef726
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss
@@ -0,0 +1,7 @@
1@import '_variables';
2@import '_mixins';
3
4my-video-playlist-miniature {
5 display: inline-block;
6 width: $video-thumbnail-width;
7}
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
index 000000000..7aee450f1
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts
@@ -0,0 +1,38 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { MiniatureDisplayOptions } from '../shared-video-miniature'
3import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist'
4
5/*
6 * Markup component that creates a playlist miniature only
7*/
8
9@Component({
10 selector: 'my-playlist-miniature-markup',
11 templateUrl: 'playlist-miniature-markup.component.html',
12 styleUrls: [ 'playlist-miniature-markup.component.scss' ]
13})
14export class PlaylistMiniatureMarkupComponent implements OnInit {
15 @Input() uuid: string
16
17 playlist: VideoPlaylist
18
19 displayOptions: MiniatureDisplayOptions = {
20 date: true,
21 views: true,
22 by: true,
23 avatar: false,
24 privacyLabel: false,
25 privacyText: false,
26 state: false,
27 blacklistInfo: false
28 }
29
30 constructor (
31 private playlistService: VideoPlaylistService
32 ) { }
33
34 ngOnInit () {
35 this.playlistService.getVideoPlaylist(this.uuid)
36 .subscribe(playlist => this.playlist = playlist)
37 }
38}
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
index 000000000..4bbb71588
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts
@@ -0,0 +1,49 @@
1
2import { CommonModule } from '@angular/common'
3import { NgModule } from '@angular/core'
4import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
5import { SharedGlobalIconModule } from '../shared-icons'
6import { SharedMainModule } from '../shared-main'
7import { SharedVideoMiniatureModule } from '../shared-video-miniature'
8import { SharedVideoPlaylistModule } from '../shared-video-playlist'
9import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
10import { CustomMarkupService } from './custom-markup.service'
11import { DynamicElementService } from './dynamic-element.service'
12import { EmbedMarkupComponent } from './embed-markup.component'
13import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
14import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
15import { VideosListMarkupComponent } from './videos-list-markup.component'
16
17@NgModule({
18 imports: [
19 CommonModule,
20
21 SharedMainModule,
22 SharedGlobalIconModule,
23 SharedVideoMiniatureModule,
24 SharedVideoPlaylistModule,
25 SharedActorImageModule
26 ],
27
28 declarations: [
29 VideoMiniatureMarkupComponent,
30 PlaylistMiniatureMarkupComponent,
31 ChannelMiniatureMarkupComponent,
32 EmbedMarkupComponent,
33 VideosListMarkupComponent
34 ],
35
36 exports: [
37 VideoMiniatureMarkupComponent,
38 PlaylistMiniatureMarkupComponent,
39 ChannelMiniatureMarkupComponent,
40 VideosListMarkupComponent,
41 EmbedMarkupComponent
42 ],
43
44 providers: [
45 CustomMarkupService,
46 DynamicElementService
47 ]
48})
49export 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
index 000000000..9b4930b6d
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html
@@ -0,0 +1,6 @@
1<my-video-miniature
2 *ngIf="video"
3 [video]="video" [user]="getUser()" [displayAsRow]="false"
4 [displayVideoActions]="false" [displayOptions]="displayOptions"
5>
6</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
index 000000000..81e265f29
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss
@@ -0,0 +1,7 @@
1@import '_variables';
2@import '_mixins';
3
4my-video-miniature {
5 display: inline-block;
6 width: $video-thumbnail-width;
7}
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
index 000000000..79add0c3b
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts
@@ -0,0 +1,44 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { AuthService } from '@app/core'
3import { Video, VideoService } from '../shared-main'
4import { MiniatureDisplayOptions } from '../shared-video-miniature'
5
6/*
7 * Markup component that creates a video miniature only
8*/
9
10@Component({
11 selector: 'my-video-miniature-markup',
12 templateUrl: 'video-miniature-markup.component.html',
13 styleUrls: [ 'video-miniature-markup.component.scss' ]
14})
15export class VideoMiniatureMarkupComponent implements OnInit {
16 @Input() uuid: string
17
18 video: Video
19
20 displayOptions: MiniatureDisplayOptions = {
21 date: true,
22 views: true,
23 by: true,
24 avatar: false,
25 privacyLabel: false,
26 privacyText: false,
27 state: false,
28 blacklistInfo: false
29 }
30
31 constructor (
32 private auth: AuthService,
33 private videoService: VideoService
34 ) { }
35
36 getUser () {
37 return this.auth.getUser()
38 }
39
40 ngOnInit () {
41 this.videoService.getVideo({ videoId: this.uuid })
42 .subscribe(video => this.video = video)
43 }
44}
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
index 000000000..501f35e04
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html
@@ -0,0 +1,13 @@
1<div class="root">
2 <h4 *ngIf="title">{{ title }}</h4>
3 <div *ngIf="description" class="description">{{ description }}</div>
4
5 <div class="videos">
6 <my-video-miniature
7 *ngFor="let video of videos"
8 [video]="video" [user]="getUser()" [displayAsRow]="false"
9 [displayVideoActions]="false" [displayOptions]="displayOptions"
10 >
11 </my-video-miniature>
12 </div>
13</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
index 000000000..dcd931090
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss
@@ -0,0 +1,9 @@
1@import '_variables';
2@import '_mixins';
3
4my-video-miniature {
5 margin-right: 15px;
6 display: inline-block;
7 min-width: $video-thumbnail-width;
8 max-width: $video-thumbnail-width;
9}
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
index 000000000..cc25d0a51
--- /dev/null
+++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts
@@ -0,0 +1,60 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { AuthService } from '@app/core'
3import { VideoSortField } from '@shared/models'
4import { Video, VideoService } from '../shared-main'
5import { MiniatureDisplayOptions } from '../shared-video-miniature'
6
7/*
8 * Markup component list videos depending on criterias
9*/
10
11@Component({
12 selector: 'my-videos-list-markup',
13 templateUrl: 'videos-list-markup.component.html',
14 styleUrls: [ 'videos-list-markup.component.scss' ]
15})
16export class VideosListMarkupComponent implements OnInit {
17 @Input() title: string
18 @Input() description: string
19 @Input() sort = '-publishedAt'
20 @Input() categoryOneOf: number[]
21 @Input() languageOneOf: string[]
22 @Input() count = 10
23
24 videos: Video[]
25
26 displayOptions: MiniatureDisplayOptions = {
27 date: true,
28 views: true,
29 by: true,
30 avatar: false,
31 privacyLabel: false,
32 privacyText: false,
33 state: false,
34 blacklistInfo: false
35 }
36
37 constructor (
38 private auth: AuthService,
39 private videoService: VideoService
40 ) { }
41
42 getUser () {
43 return this.auth.getUser()
44 }
45
46 ngOnInit () {
47 const options = {
48 videoPagination: {
49 currentPage: 1,
50 itemsPerPage: this.count
51 },
52 categoryOneOf: this.categoryOneOf,
53 languageOneOf: this.languageOneOf,
54 sort: this.sort as VideoSortField
55 }
56
57 this.videoService.getVideos(options)
58 .subscribe(({ data }) => this.videos = data)
59 }
60}
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html
index 513b543cd..6e70e2f37 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.html
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html
@@ -19,6 +19,7 @@
19 <a ngbNavLink i18n>Complete preview</a> 19 <a ngbNavLink i18n>Complete preview</a>
20 20
21 <ng-template ngbNavContent> 21 <ng-template ngbNavContent>
22 <div #previewElement></div>
22 <div [innerHTML]="previewHTML"></div> 23 <div [innerHTML]="previewHTML"></div>
23 </ng-template> 24 </ng-template>
24 </ng-container> 25 </ng-container>
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
index 9b3ab9cf3..a233a4205 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
@@ -1,9 +1,10 @@
1import { ViewportScroller } from '@angular/common'
2import truncate from 'lodash-es/truncate' 1import truncate from 'lodash-es/truncate'
3import { Subject } from 'rxjs' 2import { Subject } from 'rxjs'
4import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 3import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
4import { ViewportScroller } from '@angular/common'
5import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' 5import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
6import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 6import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
7import { SafeHtml } from '@angular/platform-browser'
7import { MarkdownService, ScreenService } from '@app/core' 8import { MarkdownService, ScreenService } from '@app/core'
8 9
9@Component({ 10@Component({
@@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core'
21 22
22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { 23export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
23 @Input() content = '' 24 @Input() content = ''
25
24 @Input() classes: string[] | { [klass: string]: any[] | any } = [] 26 @Input() classes: string[] | { [klass: string]: any[] | any } = []
27
25 @Input() textareaMaxWidth = '100%' 28 @Input() textareaMaxWidth = '100%'
26 @Input() textareaHeight = '150px' 29 @Input() textareaHeight = '150px'
30
27 @Input() truncate: number 31 @Input() truncate: number
32
28 @Input() markdownType: 'text' | 'enhanced' = 'text' 33 @Input() markdownType: 'text' | 'enhanced' = 'text'
34 @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
35
29 @Input() markdownVideo = false 36 @Input() markdownVideo = false
37
30 @Input() name = 'description' 38 @Input() name = 'description'
31 39
32 @ViewChild('textarea') textareaElement: ElementRef 40 @ViewChild('textarea') textareaElement: ElementRef
41 @ViewChild('previewElement') previewElement: ElementRef
42
43 truncatedPreviewHTML: SafeHtml | string = ''
44 previewHTML: SafeHtml | string = ''
33 45
34 truncatedPreviewHTML = ''
35 previewHTML = ''
36 isMaximized = false 46 isMaximized = false
37 47
38 maximizeInText = $localize`Maximize editor` 48 maximizeInText = $localize`Maximize editor`
@@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
115 } 125 }
116 126
117 private async markdownRender (text: string) { 127 private async markdownRender (text: string) {
118 const html = this.markdownType === 'text' ? 128 let html: string
119 await this.markdownService.textMarkdownToHTML(text) : 129
120 await this.markdownService.enhancedMarkdownToHTML(text) 130 if (this.customMarkdownRenderer) {
131 const result = await this.customMarkdownRenderer(text)
132
133 if (result instanceof HTMLElement) {
134 html = ''
135
136 const wrapperElement = this.previewElement.nativeElement as HTMLElement
137 wrapperElement.innerHTML = ''
138 wrapperElement.appendChild(result)
139 return
140 }
141
142 html = result
143 } else if (this.markdownType === 'text') {
144 html = await this.markdownService.textMarkdownToHTML(text)
145 } else {
146 html = await this.markdownService.enhancedMarkdownToHTML(text)
147 }
148
149 if (this.markdownVideo) {
150 html = this.markdownService.processVideoTimestamps(html)
151 }
121 152
122 return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html 153 return html
123 } 154 }
124} 155}
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts
index 3af517927..a4dd72db6 100644
--- a/client/src/app/shared/shared-icons/global-icon.component.ts
+++ b/client/src/app/shared/shared-icons/global-icon.component.ts
@@ -72,6 +72,7 @@ const icons = {
72 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, 72 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
73 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, 73 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
74 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, 74 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
75 'octagon': require('!!raw-loader?!../../../assets/images/feather/octagon.svg').default,
75 'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default 76 'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default
76} 77}
77 78
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
index 000000000..e5c2b3cd4
--- /dev/null
+++ b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts
@@ -0,0 +1,38 @@
1import { of } from 'rxjs'
2import { catchError, map } from 'rxjs/operators'
3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { RestExtractor } from '@app/core'
6import { CustomPage } from '@shared/models'
7import { environment } from '../../../../environments/environment'
8
9@Injectable()
10export class CustomPageService {
11 static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance'
12
13 constructor (
14 private authHttp: HttpClient,
15 private restExtractor: RestExtractor
16 ) { }
17
18 getInstanceHomepage () {
19 return this.authHttp.get<CustomPage>(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL)
20 .pipe(
21 catchError(err => {
22 if (err.status === 404) {
23 return of({ content: '' })
24 }
25
26 this.restExtractor.handleError(err)
27 })
28 )
29 }
30
31 updateInstanceHomepage (content: string) {
32 return this.authHttp.put(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL, { content })
33 .pipe(
34 map(this.restExtractor.extractDataBool),
35 catchError(err => this.restExtractor.handleError(err))
36 )
37 }
38}
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
index 000000000..7269ece95
--- /dev/null
+++ b/client/src/app/shared/shared-main/custom-page/index.ts
@@ -0,0 +1 @@
export * from './custom-page.service'
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index 772198cb2..f9b6085cf 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -29,6 +29,7 @@ import {
29} from './angular' 29} from './angular'
30import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 30import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
31import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' 31import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
32import { CustomPageService } from './custom-page'
32import { DateToggleComponent } from './date' 33import { DateToggleComponent } from './date'
33import { FeedComponent } from './feeds' 34import { FeedComponent } from './feeds'
34import { LoaderComponent, SmallLoaderComponent } from './loaders' 35import { LoaderComponent, SmallLoaderComponent } from './loaders'
@@ -171,7 +172,9 @@ import { VideoChannelService } from './video-channel'
171 172
172 VideoCaptionService, 173 VideoCaptionService,
173 174
174 VideoChannelService 175 VideoChannelService,
176
177 CustomPageService
175 ] 178 ]
176}) 179})
177export class SharedMainModule { } 180export class SharedMainModule { }
diff --git a/client/src/assets/images/feather/octagon.svg b/client/src/assets/images/feather/octagon.svg
new file mode 100644
index 000000000..1ed9bacbf
--- /dev/null
+++ b/client/src/assets/images/feather/octagon.svg
@@ -0,0 +1,3 @@
1<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">
2 <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>
3</svg>
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts
index d7451fa1d..1243526d2 100644
--- a/client/src/assets/player/utils.ts
+++ b/client/src/assets/player/utils.ts
@@ -95,7 +95,7 @@ function buildVideoLink (options: {
95function buildPlaylistLink (options: { 95function buildPlaylistLink (options: {
96 baseUrl?: string 96 baseUrl?: string
97 97
98 playlistPosition: number 98 playlistPosition?: number
99}) { 99}) {
100 const { baseUrl } = options 100 const { baseUrl } = options
101 101
diff --git a/server.ts b/server.ts
index 97dffe756..7aaf1e553 100644
--- a/server.ts
+++ b/server.ts
@@ -127,6 +127,7 @@ import { PluginManager } from './server/lib/plugins/plugin-manager'
127import { LiveManager } from './server/lib/live-manager' 127import { LiveManager } from './server/lib/live-manager'
128import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes' 128import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes'
129import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' 129import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
130import { ServerConfigManager } from '@server/lib/server-config-manager'
130 131
131// ----------- Command line ----------- 132// ----------- Command line -----------
132 133
@@ -262,7 +263,8 @@ async function startApplication () {
262 263
263 await Promise.all([ 264 await Promise.all([
264 Emailer.Instance.checkConnection(), 265 Emailer.Instance.checkConnection(),
265 JobQueue.Instance.init() 266 JobQueue.Instance.init(),
267 ServerConfigManager.Instance.init()
266 ]) 268 ])
267 269
268 // Caches initializations 270 // Caches initializations
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 5ce7adc35..c9b5c8047 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -1,8 +1,8 @@
1import { ServerConfigManager } from '@server/lib/server-config-manager'
1import * as express from 'express' 2import * as express from 'express'
2import { remove, writeJSON } from 'fs-extra' 3import { remove, writeJSON } from 'fs-extra'
3import { snakeCase } from 'lodash' 4import { snakeCase } from 'lodash'
4import validator from 'validator' 5import validator from 'validator'
5import { getServerConfig } from '@server/lib/config'
6import { UserRight } from '../../../shared' 6import { UserRight } from '../../../shared'
7import { About } from '../../../shared/models/server/about.model' 7import { About } from '../../../shared/models/server/about.model'
8import { CustomConfig } from '../../../shared/models/server/custom-config.model' 8import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@@ -43,7 +43,7 @@ configRouter.delete('/custom',
43) 43)
44 44
45async function getConfig (req: express.Request, res: express.Response) { 45async function getConfig (req: express.Request, res: express.Response) {
46 const json = await getServerConfig(req.ip) 46 const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
47 47
48 return res.json(json) 48 return res.json(json)
49} 49}
diff --git a/server/controllers/api/custom-page.ts b/server/controllers/api/custom-page.ts
new file mode 100644
index 000000000..3c47f7b9a
--- /dev/null
+++ b/server/controllers/api/custom-page.ts
@@ -0,0 +1,42 @@
1import * as express from 'express'
2import { ServerConfigManager } from '@server/lib/server-config-manager'
3import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
4import { HttpStatusCode } from '@shared/core-utils'
5import { UserRight } from '@shared/models'
6import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares'
7
8const customPageRouter = express.Router()
9
10customPageRouter.get('/homepage/instance',
11 asyncMiddleware(getInstanceHomepage)
12)
13
14customPageRouter.put('/homepage/instance',
15 authenticate,
16 ensureUserHasRight(UserRight.MANAGE_INSTANCE_CUSTOM_PAGE),
17 asyncMiddleware(updateInstanceHomepage)
18)
19
20// ---------------------------------------------------------------------------
21
22export {
23 customPageRouter
24}
25
26// ---------------------------------------------------------------------------
27
28async function getInstanceHomepage (req: express.Request, res: express.Response) {
29 const page = await ActorCustomPageModel.loadInstanceHomepage()
30 if (!page) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
31
32 return res.json(page.toFormattedJSON())
33}
34
35async function updateInstanceHomepage (req: express.Request, res: express.Response) {
36 const content = req.body.content
37
38 await ActorCustomPageModel.updateInstanceHomepage(content)
39 ServerConfigManager.Instance.updateHomepageState(content)
40
41 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
42}
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index 7ade1df3a..28378654a 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -8,6 +8,7 @@ import { abuseRouter } from './abuse'
8import { accountsRouter } from './accounts' 8import { accountsRouter } from './accounts'
9import { bulkRouter } from './bulk' 9import { bulkRouter } from './bulk'
10import { configRouter } from './config' 10import { configRouter } from './config'
11import { customPageRouter } from './custom-page'
11import { jobsRouter } from './jobs' 12import { jobsRouter } from './jobs'
12import { oauthClientsRouter } from './oauth-clients' 13import { oauthClientsRouter } from './oauth-clients'
13import { overviewsRouter } from './overviews' 14import { overviewsRouter } from './overviews'
@@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter)
47apiRouter.use('/search', searchRouter) 48apiRouter.use('/search', searchRouter)
48apiRouter.use('/overviews', overviewsRouter) 49apiRouter.use('/overviews', overviewsRouter)
49apiRouter.use('/plugins', pluginRouter) 50apiRouter.use('/plugins', pluginRouter)
51apiRouter.use('/custom-pages', customPageRouter)
50apiRouter.use('/ping', pong) 52apiRouter.use('/ping', pong)
51apiRouter.use('/*', badRequest) 53apiRouter.use('/*', badRequest)
52 54
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index ee63c7b77..0d5d7a962 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -3,7 +3,7 @@ import { move, readFile } from 'fs-extra'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { join } from 'path' 5import { join } from 'path'
6import { getEnabledResolutions } from '@server/lib/config' 6import { ServerConfigManager } from '@server/lib/server-config-manager'
7import { setVideoTags } from '@server/lib/video' 7import { setVideoTags } from '@server/lib/video'
8import { FilteredModelAttributes } from '@server/types' 8import { FilteredModelAttributes } from '@server/types'
9import { 9import {
@@ -134,7 +134,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
134 const targetUrl = body.targetUrl 134 const targetUrl = body.targetUrl
135 const user = res.locals.oauth.token.User 135 const user = res.locals.oauth.token.User
136 136
137 const youtubeDL = new YoutubeDL(targetUrl, getEnabledResolutions('vod')) 137 const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
138 138
139 // Get video infos 139 // Get video infos
140 let youtubeDLInfo: YoutubeDLInfo 140 let youtubeDLInfo: YoutubeDLInfo
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 8a747ec52..3870ebfe9 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -2,7 +2,7 @@ import * as cors from 'cors'
2import * as express from 'express' 2import * as express from 'express'
3import { join } from 'path' 3import { join } from 'path'
4import { serveIndexHTML } from '@server/lib/client-html' 4import { serveIndexHTML } from '@server/lib/client-html'
5import { getEnabledResolutions, getRegisteredPlugins, getRegisteredThemes } from '@server/lib/config' 5import { ServerConfigManager } from '@server/lib/server-config-manager'
6import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' 6import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
7import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model' 7import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
8import { root } from '../helpers/core-utils' 8import { root } from '../helpers/core-utils'
@@ -203,10 +203,10 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
203 } 203 }
204 }, 204 },
205 plugin: { 205 plugin: {
206 registered: getRegisteredPlugins() 206 registered: ServerConfigManager.Instance.getRegisteredPlugins()
207 }, 207 },
208 theme: { 208 theme: {
209 registered: getRegisteredThemes(), 209 registered: ServerConfigManager.Instance.getRegisteredThemes(),
210 default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) 210 default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
211 }, 211 },
212 email: { 212 email: {
@@ -222,13 +222,13 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
222 webtorrent: { 222 webtorrent: {
223 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED 223 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
224 }, 224 },
225 enabledResolutions: getEnabledResolutions('vod') 225 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
226 }, 226 },
227 live: { 227 live: {
228 enabled: CONFIG.LIVE.ENABLED, 228 enabled: CONFIG.LIVE.ENABLED,
229 transcoding: { 229 transcoding: {
230 enabled: CONFIG.LIVE.TRANSCODING.ENABLED, 230 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
231 enabledResolutions: getEnabledResolutions('live') 231 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
232 } 232 }
233 }, 233 },
234 import: { 234 import: {
diff --git a/server/helpers/markdown.ts b/server/helpers/markdown.ts
index 2126bb752..41e57d857 100644
--- a/server/helpers/markdown.ts
+++ b/server/helpers/markdown.ts
@@ -1,4 +1,6 @@
1import { SANITIZE_OPTIONS, TEXT_WITH_HTML_RULES } from '@shared/core-utils' 1import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
2
3const sanitizeOptions = getSanitizeOptions()
2 4
3const sanitizeHtml = require('sanitize-html') 5const sanitizeHtml = require('sanitize-html')
4const markdownItEmoji = require('markdown-it-emoji/light') 6const markdownItEmoji = require('markdown-it-emoji/light')
@@ -18,7 +20,7 @@ const toSafeHtml = text => {
18 const html = markdownIt.render(textWithLineFeed) 20 const html = markdownIt.render(textWithLineFeed)
19 21
20 // Convert to safe Html 22 // Convert to safe Html
21 return sanitizeHtml(html, SANITIZE_OPTIONS) 23 return sanitizeHtml(html, sanitizeOptions)
22} 24}
23 25
24const mdToPlainText = text => { 26const mdToPlainText = text => {
@@ -28,7 +30,7 @@ const mdToPlainText = text => {
28 const html = markdownIt.render(text) 30 const html = markdownIt.render(text)
29 31
30 // Convert to safe Html 32 // Convert to safe Html
31 const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS) 33 const safeHtml = sanitizeHtml(html, sanitizeOptions)
32 34
33 return safeHtml.replace(/<[^>]+>/g, '') 35 return safeHtml.replace(/<[^>]+>/g, '')
34 .replace(/\n$/, '') 36 .replace(/\n$/, '')
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 4cf7dcf0a..919f9ea6e 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
24 24
25// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
26 26
27const LAST_MIGRATION_VERSION = 645 27const LAST_MIGRATION_VERSION = 650
28 28
29// --------------------------------------------------------------------------- 29// ---------------------------------------------------------------------------
30 30
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 75a13ec8b..38e7a76d0 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -44,6 +44,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
44import { VideoTagModel } from '../models/video/video-tag' 44import { VideoTagModel } from '../models/video/video-tag'
45import { VideoViewModel } from '../models/video/video-view' 45import { VideoViewModel } from '../models/video/video-view'
46import { CONFIG } from './config' 46import { CONFIG } from './config'
47import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
47 48
48require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 49require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
49 50
@@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) {
141 ThumbnailModel, 142 ThumbnailModel,
142 TrackerModel, 143 TrackerModel,
143 VideoTrackerModel, 144 VideoTrackerModel,
144 PluginModel 145 PluginModel,
146 ActorCustomPageModel
145 ]) 147 ])
146 148
147 // Check extensions exist in the database 149 // 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
index 000000000..1338327e8
--- /dev/null
+++ b/server/initializers/migrations/0650-actor-custom-pages.ts
@@ -0,0 +1,33 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 {
10 const query = `
11 CREATE TABLE IF NOT EXISTS "actorCustomPage" (
12 "id" serial,
13 "content" TEXT,
14 "type" varchar(255) NOT NULL,
15 "actorId" integer NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
16 "createdAt" timestamp WITH time zone NOT NULL,
17 "updatedAt" timestamp WITH time zone NOT NULL,
18 PRIMARY KEY ("id")
19 );
20 `
21
22 await utils.sequelize.query(query)
23 }
24}
25
26function down (options) {
27 throw new Error('Not implemented.')
28}
29
30export {
31 up,
32 down
33}
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 85fdc8754..4b2968e8b 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -26,7 +26,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
26import { getActivityStreamDuration } from '../models/video/video-format-utils' 26import { getActivityStreamDuration } from '../models/video/video-format-utils'
27import { VideoPlaylistModel } from '../models/video/video-playlist' 27import { VideoPlaylistModel } from '../models/video/video-playlist'
28import { MAccountActor, MChannelActor } from '../types/models' 28import { MAccountActor, MChannelActor } from '../types/models'
29import { getHTMLServerConfig } from './config' 29import { ServerConfigManager } from './server-config-manager'
30 30
31type Tags = { 31type Tags = {
32 ogType: string 32 ogType: string
@@ -211,7 +211,7 @@ class ClientHtml {
211 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] 211 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
212 212
213 const buffer = await readFile(path) 213 const buffer = await readFile(path)
214 const serverConfig = await getHTMLServerConfig() 214 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
215 215
216 let html = buffer.toString() 216 let html = buffer.toString()
217 html = await ClientHtml.addAsyncPluginCSS(html) 217 html = await ClientHtml.addAsyncPluginCSS(html)
@@ -280,7 +280,7 @@ class ClientHtml {
280 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] 280 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
281 281
282 const buffer = await readFile(path) 282 const buffer = await readFile(path)
283 const serverConfig = await getHTMLServerConfig() 283 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
284 284
285 let html = buffer.toString() 285 let html = buffer.toString()
286 286
diff --git a/server/lib/config.ts b/server/lib/config.ts
deleted file mode 100644
index 18d49f05a..000000000
--- a/server/lib/config.ts
+++ /dev/null
@@ -1,274 +0,0 @@
1import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup'
2import { getServerCommit } from '@server/helpers/utils'
3import { CONFIG, isEmailEnabled } from '@server/initializers/config'
4import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
5import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
6import { Hooks } from './plugins/hooks'
7import { PluginManager } from './plugins/plugin-manager'
8import { getThemeOrDefault } from './plugins/theme-utils'
9import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
10
11async function getServerConfig (ip?: string): Promise<ServerConfig> {
12 const { allowed } = await Hooks.wrapPromiseFun(
13 isSignupAllowed,
14 {
15 ip
16 },
17 'filter:api.user.signup.allowed.result'
18 )
19
20 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
21
22 const signup = {
23 allowed,
24 allowedForCurrentIP,
25 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
26 }
27
28 const htmlConfig = await getHTMLServerConfig()
29
30 return { ...htmlConfig, signup }
31}
32
33// Config injected in HTML
34let serverCommit: string
35async function getHTMLServerConfig (): Promise<HTMLServerConfig> {
36 if (serverCommit === undefined) serverCommit = await getServerCommit()
37
38 const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
39
40 return {
41 instance: {
42 name: CONFIG.INSTANCE.NAME,
43 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
44 isNSFW: CONFIG.INSTANCE.IS_NSFW,
45 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
46 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
47 customizations: {
48 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
49 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
50 }
51 },
52 search: {
53 remoteUri: {
54 users: CONFIG.SEARCH.REMOTE_URI.USERS,
55 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
56 },
57 searchIndex: {
58 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
59 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
60 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
61 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
62 }
63 },
64 plugin: {
65 registered: getRegisteredPlugins(),
66 registeredExternalAuths: getExternalAuthsPlugins(),
67 registeredIdAndPassAuths: getIdAndPassAuthPlugins()
68 },
69 theme: {
70 registered: getRegisteredThemes(),
71 default: defaultTheme
72 },
73 email: {
74 enabled: isEmailEnabled()
75 },
76 contactForm: {
77 enabled: CONFIG.CONTACT_FORM.ENABLED
78 },
79 serverVersion: PEERTUBE_VERSION,
80 serverCommit,
81 transcoding: {
82 hls: {
83 enabled: CONFIG.TRANSCODING.HLS.ENABLED
84 },
85 webtorrent: {
86 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
87 },
88 enabledResolutions: getEnabledResolutions('vod'),
89 profile: CONFIG.TRANSCODING.PROFILE,
90 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
91 },
92 live: {
93 enabled: CONFIG.LIVE.ENABLED,
94
95 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
96 maxDuration: CONFIG.LIVE.MAX_DURATION,
97 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
98 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
99
100 transcoding: {
101 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
102 enabledResolutions: getEnabledResolutions('live'),
103 profile: CONFIG.LIVE.TRANSCODING.PROFILE,
104 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
105 },
106
107 rtmp: {
108 port: CONFIG.LIVE.RTMP.PORT
109 }
110 },
111 import: {
112 videos: {
113 http: {
114 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
115 },
116 torrent: {
117 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
118 }
119 }
120 },
121 autoBlacklist: {
122 videos: {
123 ofUsers: {
124 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
125 }
126 }
127 },
128 avatar: {
129 file: {
130 size: {
131 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
132 },
133 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
134 }
135 },
136 banner: {
137 file: {
138 size: {
139 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
140 },
141 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
142 }
143 },
144 video: {
145 image: {
146 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
147 size: {
148 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
149 }
150 },
151 file: {
152 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
153 }
154 },
155 videoCaption: {
156 file: {
157 size: {
158 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
159 },
160 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
161 }
162 },
163 user: {
164 videoQuota: CONFIG.USER.VIDEO_QUOTA,
165 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
166 },
167 trending: {
168 videos: {
169 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
170 algorithms: {
171 enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
172 default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
173 }
174 }
175 },
176 tracker: {
177 enabled: CONFIG.TRACKER.ENABLED
178 },
179
180 followings: {
181 instance: {
182 autoFollowIndex: {
183 indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
184 }
185 }
186 },
187
188 broadcastMessage: {
189 enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
190 message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
191 level: CONFIG.BROADCAST_MESSAGE.LEVEL,
192 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
193 }
194 }
195}
196
197function getRegisteredThemes () {
198 return PluginManager.Instance.getRegisteredThemes()
199 .map(t => ({
200 name: t.name,
201 version: t.version,
202 description: t.description,
203 css: t.css,
204 clientScripts: t.clientScripts
205 }))
206}
207
208function getRegisteredPlugins () {
209 return PluginManager.Instance.getRegisteredPlugins()
210 .map(p => ({
211 name: p.name,
212 version: p.version,
213 description: p.description,
214 clientScripts: p.clientScripts
215 }))
216}
217
218function getEnabledResolutions (type: 'vod' | 'live') {
219 const transcoding = type === 'vod'
220 ? CONFIG.TRANSCODING
221 : CONFIG.LIVE.TRANSCODING
222
223 return Object.keys(transcoding.RESOLUTIONS)
224 .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
225 .map(r => parseInt(r, 10))
226}
227
228// ---------------------------------------------------------------------------
229
230export {
231 getServerConfig,
232 getRegisteredThemes,
233 getEnabledResolutions,
234 getRegisteredPlugins,
235 getHTMLServerConfig
236}
237
238// ---------------------------------------------------------------------------
239
240function getIdAndPassAuthPlugins () {
241 const result: RegisteredIdAndPassAuthConfig[] = []
242
243 for (const p of PluginManager.Instance.getIdAndPassAuths()) {
244 for (const auth of p.idAndPassAuths) {
245 result.push({
246 npmName: p.npmName,
247 name: p.name,
248 version: p.version,
249 authName: auth.authName,
250 weight: auth.getWeight()
251 })
252 }
253 }
254
255 return result
256}
257
258function getExternalAuthsPlugins () {
259 const result: RegisteredExternalAuthConfig[] = []
260
261 for (const p of PluginManager.Instance.getExternalAuths()) {
262 for (const auth of p.externalAuths) {
263 result.push({
264 npmName: p.npmName,
265 name: p.name,
266 version: p.version,
267 authName: auth.authName,
268 authDisplayName: auth.authDisplayName()
269 })
270 }
271 }
272
273 return result
274}
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index 3067ce214..d71053e87 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -2,8 +2,10 @@ import * as Bull from 'bull'
2import { move, remove, stat } from 'fs-extra' 2import { move, remove, stat } from 'fs-extra'
3import { extname } from 'path' 3import { extname } from 'path'
4import { retryTransactionWrapper } from '@server/helpers/database-utils' 4import { retryTransactionWrapper } from '@server/helpers/database-utils'
5import { YoutubeDL } from '@server/helpers/youtube-dl'
5import { isPostImportVideoAccepted } from '@server/lib/moderation' 6import { isPostImportVideoAccepted } from '@server/lib/moderation'
6import { Hooks } from '@server/lib/plugins/hooks' 7import { Hooks } from '@server/lib/plugins/hooks'
8import { ServerConfigManager } from '@server/lib/server-config-manager'
7import { isAbleToUploadVideo } from '@server/lib/user' 9import { isAbleToUploadVideo } from '@server/lib/user'
8import { addOptimizeOrMergeAudioJob } from '@server/lib/video' 10import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
9import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 11import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
@@ -33,8 +35,6 @@ import { MThumbnail } from '../../../types/models/video/thumbnail'
33import { federateVideoIfNeeded } from '../../activitypub/videos' 35import { federateVideoIfNeeded } from '../../activitypub/videos'
34import { Notifier } from '../../notifier' 36import { Notifier } from '../../notifier'
35import { generateVideoMiniature } from '../../thumbnail' 37import { generateVideoMiniature } from '../../thumbnail'
36import { YoutubeDL } from '@server/helpers/youtube-dl'
37import { getEnabledResolutions } from '@server/lib/config'
38 38
39async function processVideoImport (job: Bull.Job) { 39async function processVideoImport (job: Bull.Job) {
40 const payload = job.data as VideoImportPayload 40 const payload = job.data as VideoImportPayload
@@ -76,7 +76,7 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub
76 videoImportId: videoImport.id 76 videoImportId: videoImport.id
77 } 77 }
78 78
79 const youtubeDL = new YoutubeDL(videoImport.targetUrl, getEnabledResolutions('vod')) 79 const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
80 80
81 return processFile( 81 return processFile(
82 () => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT), 82 () => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT),
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index cb1cd4d9a..8487672ba 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -15,7 +15,7 @@ import { MPlugin } from '@server/types/models'
15import { PeerTubeHelpers } from '@server/types/plugins' 15import { PeerTubeHelpers } from '@server/types/plugins'
16import { VideoBlacklistCreate } from '@shared/models' 16import { VideoBlacklistCreate } from '@shared/models'
17import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist' 17import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
18import { getServerConfig } from '../config' 18import { ServerConfigManager } from '../server-config-manager'
19import { blacklistVideo, unblacklistVideo } from '../video-blacklist' 19import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
20import { UserModel } from '@server/models/user/user' 20import { UserModel } from '@server/models/user/user'
21 21
@@ -147,7 +147,7 @@ function buildConfigHelpers () {
147 }, 147 },
148 148
149 getServerConfig () { 149 getServerConfig () {
150 return getServerConfig() 150 return ServerConfigManager.Instance.getServerConfig()
151 } 151 }
152 } 152 }
153} 153}
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
new file mode 100644
index 000000000..1aff6f446
--- /dev/null
+++ b/server/lib/server-config-manager.ts
@@ -0,0 +1,303 @@
1import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup'
2import { getServerCommit } from '@server/helpers/utils'
3import { CONFIG, isEmailEnabled } from '@server/initializers/config'
4import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
5import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
6import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
7import { Hooks } from './plugins/hooks'
8import { PluginManager } from './plugins/plugin-manager'
9import { getThemeOrDefault } from './plugins/theme-utils'
10import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
11
12/**
13 *
14 * Used to send the server config to clients (using REST/API or plugins API)
15 * We need a singleton class to manage config state depending on external events (to build menu entries etc)
16 *
17 */
18
19class ServerConfigManager {
20
21 private static instance: ServerConfigManager
22
23 private serverCommit: string
24
25 private homepageEnabled = false
26
27 private constructor () {}
28
29 async init () {
30 const instanceHomepage = await ActorCustomPageModel.loadInstanceHomepage()
31
32 this.updateHomepageState(instanceHomepage?.content)
33 }
34
35 updateHomepageState (content: string) {
36 this.homepageEnabled = !!content
37 }
38
39 async getHTMLServerConfig (): Promise<HTMLServerConfig> {
40 if (this.serverCommit === undefined) this.serverCommit = await getServerCommit()
41
42 const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
43
44 return {
45 instance: {
46 name: CONFIG.INSTANCE.NAME,
47 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
48 isNSFW: CONFIG.INSTANCE.IS_NSFW,
49 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
50 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
51 customizations: {
52 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
53 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
54 }
55 },
56 search: {
57 remoteUri: {
58 users: CONFIG.SEARCH.REMOTE_URI.USERS,
59 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
60 },
61 searchIndex: {
62 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
63 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
64 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
65 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
66 }
67 },
68 plugin: {
69 registered: this.getRegisteredPlugins(),
70 registeredExternalAuths: this.getExternalAuthsPlugins(),
71 registeredIdAndPassAuths: this.getIdAndPassAuthPlugins()
72 },
73 theme: {
74 registered: this.getRegisteredThemes(),
75 default: defaultTheme
76 },
77 email: {
78 enabled: isEmailEnabled()
79 },
80 contactForm: {
81 enabled: CONFIG.CONTACT_FORM.ENABLED
82 },
83 serverVersion: PEERTUBE_VERSION,
84 serverCommit: this.serverCommit,
85 transcoding: {
86 hls: {
87 enabled: CONFIG.TRANSCODING.HLS.ENABLED
88 },
89 webtorrent: {
90 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
91 },
92 enabledResolutions: this.getEnabledResolutions('vod'),
93 profile: CONFIG.TRANSCODING.PROFILE,
94 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
95 },
96 live: {
97 enabled: CONFIG.LIVE.ENABLED,
98
99 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
100 maxDuration: CONFIG.LIVE.MAX_DURATION,
101 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
102 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
103
104 transcoding: {
105 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
106 enabledResolutions: this.getEnabledResolutions('live'),
107 profile: CONFIG.LIVE.TRANSCODING.PROFILE,
108 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
109 },
110
111 rtmp: {
112 port: CONFIG.LIVE.RTMP.PORT
113 }
114 },
115 import: {
116 videos: {
117 http: {
118 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
119 },
120 torrent: {
121 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
122 }
123 }
124 },
125 autoBlacklist: {
126 videos: {
127 ofUsers: {
128 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
129 }
130 }
131 },
132 avatar: {
133 file: {
134 size: {
135 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
136 },
137 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
138 }
139 },
140 banner: {
141 file: {
142 size: {
143 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
144 },
145 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
146 }
147 },
148 video: {
149 image: {
150 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
151 size: {
152 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
153 }
154 },
155 file: {
156 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
157 }
158 },
159 videoCaption: {
160 file: {
161 size: {
162 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
163 },
164 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
165 }
166 },
167 user: {
168 videoQuota: CONFIG.USER.VIDEO_QUOTA,
169 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
170 },
171 trending: {
172 videos: {
173 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
174 algorithms: {
175 enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
176 default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
177 }
178 }
179 },
180 tracker: {
181 enabled: CONFIG.TRACKER.ENABLED
182 },
183
184 followings: {
185 instance: {
186 autoFollowIndex: {
187 indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
188 }
189 }
190 },
191
192 broadcastMessage: {
193 enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
194 message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
195 level: CONFIG.BROADCAST_MESSAGE.LEVEL,
196 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
197 },
198
199 homepage: {
200 enabled: this.homepageEnabled
201 }
202 }
203 }
204
205 async getServerConfig (ip?: string): Promise<ServerConfig> {
206 const { allowed } = await Hooks.wrapPromiseFun(
207 isSignupAllowed,
208 {
209 ip
210 },
211 'filter:api.user.signup.allowed.result'
212 )
213
214 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
215
216 const signup = {
217 allowed,
218 allowedForCurrentIP,
219 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
220 }
221
222 const htmlConfig = await this.getHTMLServerConfig()
223
224 return { ...htmlConfig, signup }
225 }
226
227 getRegisteredThemes () {
228 return PluginManager.Instance.getRegisteredThemes()
229 .map(t => ({
230 name: t.name,
231 version: t.version,
232 description: t.description,
233 css: t.css,
234 clientScripts: t.clientScripts
235 }))
236 }
237
238 getRegisteredPlugins () {
239 return PluginManager.Instance.getRegisteredPlugins()
240 .map(p => ({
241 name: p.name,
242 version: p.version,
243 description: p.description,
244 clientScripts: p.clientScripts
245 }))
246 }
247
248 getEnabledResolutions (type: 'vod' | 'live') {
249 const transcoding = type === 'vod'
250 ? CONFIG.TRANSCODING
251 : CONFIG.LIVE.TRANSCODING
252
253 return Object.keys(transcoding.RESOLUTIONS)
254 .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
255 .map(r => parseInt(r, 10))
256 }
257
258 private getIdAndPassAuthPlugins () {
259 const result: RegisteredIdAndPassAuthConfig[] = []
260
261 for (const p of PluginManager.Instance.getIdAndPassAuths()) {
262 for (const auth of p.idAndPassAuths) {
263 result.push({
264 npmName: p.npmName,
265 name: p.name,
266 version: p.version,
267 authName: auth.authName,
268 weight: auth.getWeight()
269 })
270 }
271 }
272
273 return result
274 }
275
276 private getExternalAuthsPlugins () {
277 const result: RegisteredExternalAuthConfig[] = []
278
279 for (const p of PluginManager.Instance.getExternalAuths()) {
280 for (const auth of p.externalAuths) {
281 result.push({
282 npmName: p.npmName,
283 name: p.name,
284 version: p.version,
285 authName: auth.authName,
286 authDisplayName: auth.authDisplayName()
287 })
288 }
289 }
290
291 return result
292 }
293
294 static get Instance () {
295 return this.instance || (this.instance = new this())
296 }
297}
298
299// ---------------------------------------------------------------------------
300
301export {
302 ServerConfigManager
303}
diff --git a/server/models/account/actor-custom-page.ts b/server/models/account/actor-custom-page.ts
new file mode 100644
index 000000000..893023181
--- /dev/null
+++ b/server/models/account/actor-custom-page.ts
@@ -0,0 +1,69 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { CustomPage } from '@shared/models'
3import { ActorModel } from '../actor/actor'
4import { getServerActor } from '../application/application'
5
6@Table({
7 tableName: 'actorCustomPage',
8 indexes: [
9 {
10 fields: [ 'actorId', 'type' ],
11 unique: true
12 }
13 ]
14})
15export class ActorCustomPageModel extends Model {
16
17 @AllowNull(true)
18 @Column(DataType.TEXT)
19 content: string
20
21 @AllowNull(false)
22 @Column
23 type: 'homepage'
24
25 @CreatedAt
26 createdAt: Date
27
28 @UpdatedAt
29 updatedAt: Date
30
31 @ForeignKey(() => ActorModel)
32 @Column
33 actorId: number
34
35 @BelongsTo(() => ActorModel, {
36 foreignKey: {
37 name: 'actorId',
38 allowNull: false
39 },
40 onDelete: 'cascade'
41 })
42 Actor: ActorModel
43
44 static async updateInstanceHomepage (content: string) {
45 const serverActor = await getServerActor()
46
47 return ActorCustomPageModel.upsert({
48 content,
49 actorId: serverActor.id,
50 type: 'homepage'
51 })
52 }
53
54 static async loadInstanceHomepage () {
55 const serverActor = await getServerActor()
56
57 return ActorCustomPageModel.findOne({
58 where: {
59 actorId: serverActor.id
60 }
61 })
62 }
63
64 toFormattedJSON (): CustomPage {
65 return {
66 content: this.content
67 }
68 }
69}
diff --git a/server/tests/api/check-params/custom-pages.ts b/server/tests/api/check-params/custom-pages.ts
new file mode 100644
index 000000000..74ca3384c
--- /dev/null
+++ b/server/tests/api/check-params/custom-pages.ts
@@ -0,0 +1,81 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import {
6 cleanupTests,
7 createUser,
8 flushAndRunServer,
9 ServerInfo,
10 setAccessTokensToServers,
11 userLogin
12} from '../../../../shared/extra-utils'
13import { makeGetRequest, makePutBodyRequest } from '../../../../shared/extra-utils/requests/requests'
14
15describe('Test custom pages validators', function () {
16 const path = '/api/v1/custom-pages/homepage/instance'
17
18 let server: ServerInfo
19 let userAccessToken: string
20
21 // ---------------------------------------------------------------
22
23 before(async function () {
24 this.timeout(120000)
25
26 server = await flushAndRunServer(1)
27 await setAccessTokensToServers([ server ])
28
29 const user = { username: 'user1', password: 'password' }
30 await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
31
32 userAccessToken = await userLogin(server, user)
33 })
34
35 describe('When updating instance homepage', function () {
36
37 it('Should fail with an unauthenticated user', async function () {
38 await makePutBodyRequest({
39 url: server.url,
40 path,
41 fields: { content: 'super content' },
42 statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401
43 })
44 })
45
46 it('Should fail with a non admin user', async function () {
47 await makePutBodyRequest({
48 url: server.url,
49 path,
50 token: userAccessToken,
51 fields: { content: 'super content' },
52 statusCodeExpected: HttpStatusCode.FORBIDDEN_403
53 })
54 })
55
56 it('Should succeed with the correct params', async function () {
57 await makePutBodyRequest({
58 url: server.url,
59 path,
60 token: server.accessToken,
61 fields: { content: 'super content' },
62 statusCodeExpected: HttpStatusCode.NO_CONTENT_204
63 })
64 })
65 })
66
67 describe('When getting instance homapage', function () {
68
69 it('Should succeed with the correct params', async function () {
70 await makeGetRequest({
71 url: server.url,
72 path,
73 statusCodeExpected: HttpStatusCode.OK_200
74 })
75 })
76 })
77
78 after(async function () {
79 await cleanupTests([ server ])
80 })
81})
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 143515838..ce2335e42 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -3,6 +3,7 @@ import './accounts'
3import './blocklist' 3import './blocklist'
4import './bulk' 4import './bulk'
5import './config' 5import './config'
6import './custom-pages'
6import './contact-form' 7import './contact-form'
7import './debug' 8import './debug'
8import './follows' 9import './follows'
diff --git a/server/tests/api/server/homepage.ts b/server/tests/api/server/homepage.ts
new file mode 100644
index 000000000..e8ba89ca6
--- /dev/null
+++ b/server/tests/api/server/homepage.ts
@@ -0,0 +1,85 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { HttpStatusCode } from '@shared/core-utils'
6import { CustomPage, ServerConfig } from '@shared/models'
7import {
8 cleanupTests,
9 flushAndRunServer,
10 getConfig,
11 getInstanceHomepage,
12 killallServers,
13 reRunServer,
14 ServerInfo,
15 setAccessTokensToServers,
16 updateInstanceHomepage
17} from '../../../../shared/extra-utils/index'
18
19const expect = chai.expect
20
21async function getHomepageState (server: ServerInfo) {
22 const res = await getConfig(server.url)
23
24 const config = res.body as ServerConfig
25 return config.homepage.enabled
26}
27
28describe('Test instance homepage actions', function () {
29 let server: ServerInfo
30
31 before(async function () {
32 this.timeout(30000)
33
34 server = await flushAndRunServer(1)
35 await setAccessTokensToServers([ server ])
36 })
37
38 it('Should not have a homepage', async function () {
39 const state = await getHomepageState(server)
40 expect(state).to.be.false
41
42 await getInstanceHomepage(server.url, HttpStatusCode.NOT_FOUND_404)
43 })
44
45 it('Should set a homepage', async function () {
46 await updateInstanceHomepage(server.url, server.accessToken, '<picsou-magazine></picsou-magazine>')
47
48 const res = await getInstanceHomepage(server.url)
49 const page: CustomPage = res.body
50 expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
51
52 const state = await getHomepageState(server)
53 expect(state).to.be.true
54 })
55
56 it('Should have the same homepage after a restart', async function () {
57 this.timeout(30000)
58
59 killallServers([ server ])
60
61 await reRunServer(server)
62
63 const res = await getInstanceHomepage(server.url)
64 const page: CustomPage = res.body
65 expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
66
67 const state = await getHomepageState(server)
68 expect(state).to.be.true
69 })
70
71 it('Should empty the homepage', async function () {
72 await updateInstanceHomepage(server.url, server.accessToken, '')
73
74 const res = await getInstanceHomepage(server.url)
75 const page: CustomPage = res.body
76 expect(page.content).to.be.empty
77
78 const state = await getHomepageState(server)
79 expect(state).to.be.false
80 })
81
82 after(async function () {
83 await cleanupTests([ server ])
84 })
85})
diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts
index be743973a..56e6eb5da 100644
--- a/server/tests/api/server/index.ts
+++ b/server/tests/api/server/index.ts
@@ -5,6 +5,7 @@ import './email'
5import './follow-constraints' 5import './follow-constraints'
6import './follows' 6import './follows'
7import './follows-moderation' 7import './follows-moderation'
8import './homepage'
8import './handle-down' 9import './handle-down'
9import './jobs' 10import './jobs'
10import './logs' 11import './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
index 000000000..2cb8aa7e4
--- /dev/null
+++ b/server/types/models/account/actor-custom-page.ts
@@ -0,0 +1,4 @@
1
2import { ActorCustomPageModel } from '../../../models/account/actor-custom-page'
3
4export type MActorCustomPage = Omit<ActorCustomPageModel, 'Actor'>
diff --git a/server/types/models/account/index.ts b/server/types/models/account/index.ts
index dab2eea7e..9679c01e4 100644
--- a/server/types/models/account/index.ts
+++ b/server/types/models/account/index.ts
@@ -1,2 +1,3 @@
1export * from './account' 1export * from './account'
2export * from './actor-custom-page'
2export * from './account-blocklist' 3export * from './account-blocklist'
diff --git a/shared/core-utils/miscs/miscs.ts b/shared/core-utils/miscs/miscs.ts
index 71703faac..4780ca922 100644
--- a/shared/core-utils/miscs/miscs.ts
+++ b/shared/core-utils/miscs/miscs.ts
@@ -28,9 +28,24 @@ function isCatchable (value: any) {
28 return value && typeof value.catch === 'function' 28 return value && typeof value.catch === 'function'
29} 29}
30 30
31function sortObjectComparator (key: string, order: 'asc' | 'desc') {
32 return (a: any, b: any) => {
33 if (a[key] < b[key]) {
34 return order === 'asc' ? -1 : 1
35 }
36
37 if (a[key] > b[key]) {
38 return order === 'asc' ? 1 : -1
39 }
40
41 return 0
42 }
43}
44
31export { 45export {
32 randomInt, 46 randomInt,
33 compareSemVer, 47 compareSemVer,
34 isPromise, 48 isPromise,
35 isCatchable 49 isCatchable,
50 sortObjectComparator
36} 51}
diff --git a/shared/core-utils/renderer/html.ts b/shared/core-utils/renderer/html.ts
index de4ad47ac..bbf8b3fbd 100644
--- a/shared/core-utils/renderer/html.ts
+++ b/shared/core-utils/renderer/html.ts
@@ -1,25 +1,45 @@
1export const SANITIZE_OPTIONS = { 1export function getSanitizeOptions () {
2 allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], 2 return {
3 allowedSchemes: [ 'http', 'https' ], 3 allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
4 allowedAttributes: { 4 allowedSchemes: [ 'http', 'https' ],
5 a: [ 'href', 'class', 'target', 'rel' ] 5 allowedAttributes: {
6 }, 6 'a': [ 'href', 'class', 'target', 'rel' ],
7 transformTags: { 7 '*': [ 'data-*' ]
8 a: (tagName: string, attribs: any) => { 8 },
9 let rel = 'noopener noreferrer' 9 transformTags: {
10 if (attribs.rel === 'me') rel += ' me' 10 a: (tagName: string, attribs: any) => {
11 let rel = 'noopener noreferrer'
12 if (attribs.rel === 'me') rel += ' me'
11 13
12 return { 14 return {
13 tagName, 15 tagName,
14 attribs: Object.assign(attribs, { 16 attribs: Object.assign(attribs, {
15 target: '_blank', 17 target: '_blank',
16 rel 18 rel
17 }) 19 })
20 }
18 } 21 }
19 } 22 }
20 } 23 }
21} 24}
22 25
26export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) {
27 const base = getSanitizeOptions()
28
29 return {
30 allowedTags: [
31 ...base.allowedTags,
32 ...additionalAllowedTags,
33 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
34 ],
35 allowedSchemes: base.allowedSchemes,
36 allowedAttributes: {
37 ...base.allowedAttributes,
38 '*': [ 'data-*', 'style' ]
39 }
40 }
41}
42
23// Thanks: https://stackoverflow.com/a/12034334 43// Thanks: https://stackoverflow.com/a/12034334
24export function escapeHTML (stringParam: string) { 44export function escapeHTML (stringParam: string) {
25 if (!stringParam) return '' 45 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
index 000000000..bf2d16c70
--- /dev/null
+++ b/shared/extra-utils/custom-pages/custom-pages.ts
@@ -0,0 +1,31 @@
1import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
2import { makeGetRequest, makePutBodyRequest } from '../requests/requests'
3
4function getInstanceHomepage (url: string, statusCodeExpected = HttpStatusCode.OK_200) {
5 const path = '/api/v1/custom-pages/homepage/instance'
6
7 return makeGetRequest({
8 url,
9 path,
10 statusCodeExpected
11 })
12}
13
14function updateInstanceHomepage (url: string, token: string, content: string) {
15 const path = '/api/v1/custom-pages/homepage/instance'
16
17 return makePutBodyRequest({
18 url,
19 path,
20 token,
21 fields: { content },
22 statusCodeExpected: HttpStatusCode.NO_CONTENT_204
23 })
24}
25
26// ---------------------------------------------------------------------------
27
28export {
29 getInstanceHomepage,
30 updateInstanceHomepage
31}
diff --git a/shared/extra-utils/index.ts b/shared/extra-utils/index.ts
index 720db19cb..3bc09ead5 100644
--- a/shared/extra-utils/index.ts
+++ b/shared/extra-utils/index.ts
@@ -2,6 +2,8 @@ export * from './bulk/bulk'
2 2
3export * from './cli/cli' 3export * from './cli/cli'
4 4
5export * from './custom-pages/custom-pages'
6
5export * from './feeds/feeds' 7export * from './feeds/feeds'
6 8
7export * from './mock-servers/mock-instances-index' 9export * 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
index 000000000..1e33584c1
--- /dev/null
+++ b/shared/models/actors/custom-page.model.ts
@@ -0,0 +1,3 @@
1export interface CustomPage {
2 content: string
3}
diff --git a/shared/models/actors/index.ts b/shared/models/actors/index.ts
index 156f83248..e03f168cd 100644
--- a/shared/models/actors/index.ts
+++ b/shared/models/actors/index.ts
@@ -2,4 +2,5 @@ export * from './account.model'
2export * from './actor-image.model' 2export * from './actor-image.model'
3export * from './actor-image.type' 3export * from './actor-image.type'
4export * from './actor.model' 4export * from './actor.model'
5export * from './custom-page.model'
5export * from './follow.model' 6export * 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
index 000000000..af697428e
--- /dev/null
+++ b/shared/models/custom-markup/custom-markup-data.model.ts
@@ -0,0 +1,28 @@
1export type EmbedMarkupData = {
2 // Video or playlist uuid
3 uuid: string
4}
5
6export type VideoMiniatureMarkupData = {
7 // Video uuid
8 uuid: string
9}
10
11export type PlaylistMiniatureMarkupData = {
12 // Playlist uuid
13 uuid: string
14}
15
16export type ChannelMiniatureMarkupData = {
17 // Channel name (username)
18 name: string
19}
20
21export type VideosListMarkupData = {
22 title: string
23 description: string
24 sort: string
25 categoryOneOf: string // coma separated values
26 languageOneOf: string // coma separated values
27 count: string
28}
diff --git a/shared/models/custom-markup/index.ts b/shared/models/custom-markup/index.ts
new file mode 100644
index 000000000..2898dfa90
--- /dev/null
+++ b/shared/models/custom-markup/index.ts
@@ -0,0 +1 @@
export * from './custom-markup-data.model'
diff --git a/shared/models/index.ts b/shared/models/index.ts
index dff5fdf0e..4db1f234e 100644
--- a/shared/models/index.ts
+++ b/shared/models/index.ts
@@ -1,6 +1,7 @@
1export * from './activitypub' 1export * from './activitypub'
2export * from './actors' 2export * from './actors'
3export * from './moderation' 3export * from './moderation'
4export * from './custom-markup'
4export * from './bulk' 5export * from './bulk'
5export * from './redundancy' 6export * from './redundancy'
6export * from './users' 7export * from './users'
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 2c5026b30..1667bc0e2 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -214,6 +214,10 @@ export interface ServerConfig {
214 level: BroadcastMessageLevel 214 level: BroadcastMessageLevel
215 dismissable: boolean 215 dismissable: boolean
216 } 216 }
217
218 homepage: {
219 enabled: boolean
220 }
217} 221}
218 222
219export type HTMLServerConfig = Omit<ServerConfig, 'signup'> 223export type HTMLServerConfig = Omit<ServerConfig, 'signup'>
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts
index bbedc9f00..950b22bad 100644
--- a/shared/models/users/user-right.enum.ts
+++ b/shared/models/users/user-right.enum.ts
@@ -16,6 +16,7 @@ export const enum UserRight {
16 MANAGE_JOBS, 16 MANAGE_JOBS,
17 17
18 MANAGE_CONFIGURATION, 18 MANAGE_CONFIGURATION,
19 MANAGE_INSTANCE_CUSTOM_PAGE,
19 20
20 MANAGE_ACCOUNTS_BLOCKLIST, 21 MANAGE_ACCOUNTS_BLOCKLIST,
21 MANAGE_SERVERS_BLOCKLIST, 22 MANAGE_SERVERS_BLOCKLIST,
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 11adf078d..74910c313 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -247,6 +247,8 @@ tags:
247 247
248 Administrators can also enable the use of a remote search system, indexing 248 Administrators can also enable the use of a remote search system, indexing
249 videos and channels not could be not federated by the instance. 249 videos and channels not could be not federated by the instance.
250 - name: Homepage
251 description: Get and update the custom homepage
250 - name: Video Mirroring 252 - name: Video Mirroring
251 description: | 253 description: |
252 PeerTube instances can mirror videos from one another, and help distribute some videos. 254 PeerTube instances can mirror videos from one another, and help distribute some videos.
@@ -281,6 +283,9 @@ x-tagGroups:
281 - name: Search 283 - name: Search
282 tags: 284 tags:
283 - Search 285 - Search
286 - name: Custom pages
287 tags:
288 - Homepage
284 - name: Moderation 289 - name: Moderation
285 tags: 290 tags:
286 - Abuses 291 - Abuses
@@ -477,6 +482,40 @@ paths:
477 '200': 482 '200':
478 description: successful operation 483 description: successful operation
479 484
485 /custom-pages/homepage/instance:
486 get:
487 summary: Get instance custom homepage
488 tags:
489 - Homepage
490 responses:
491 '404':
492 description: No homepage set
493 '200':
494 description: successful operation
495 content:
496 application/json:
497 schema:
498 $ref: '#/components/schemas/CustomHomepage'
499 put:
500 summary: Set instance custom homepage
501 tags:
502 - Homepage
503 security:
504 - OAuth2:
505 - admin
506 requestBody:
507 content:
508 application/json:
509 schema:
510 type: object
511 properties:
512 content:
513 type: string
514 description: content of the homepage, that will be injected in the client
515 responses:
516 '204':
517 description: successful operation
518
480 /jobs/{state}: 519 /jobs/{state}:
481 get: 520 get:
482 summary: List instance jobs 521 summary: List instance jobs
@@ -5740,6 +5779,12 @@ components:
5740 indexUrl: 5779 indexUrl:
5741 type: string 5780 type: string
5742 format: url 5781 format: url
5782 homepage:
5783 type: object
5784 properties:
5785 enabled:
5786 type: boolean
5787
5743 ServerConfigAbout: 5788 ServerConfigAbout:
5744 properties: 5789 properties:
5745 instance: 5790 instance:
@@ -5930,6 +5975,12 @@ components:
5930 type: boolean 5975 type: boolean
5931 manualApproval: 5976 manualApproval:
5932 type: boolean 5977 type: boolean
5978
5979 CustomHomepage:
5980 properties:
5981 content:
5982 type: string
5983
5933 Follow: 5984 Follow:
5934 properties: 5985 properties:
5935 id: 5986 id: