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