Instance homepage support (#4007)

* Prepare homepage parsers

* Add ability to update instance hompage

* Add ability to set homepage as landing page

* Add homepage preview in admin

* Dynamically update left menu for homepage

* Inject home content in homepage

* Add videos list and channel miniature custom markup

* Remove unused elements in markup service
pull/4105/head^2
Chocobozzz 2021-05-27 15:59:55 +02:00 committed by GitHub
parent eb34ec30e0
commit 2539932e16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 1761 additions and 407 deletions

View File

@ -14,6 +14,6 @@ export class AboutPeertubeContributorsComponent implements OnInit {
constructor (private markdownService: MarkdownService) { }
async ngOnInit () {
this.creditsHtml = await this.markdownService.completeMarkdownToHTML(this.markdown)
this.creditsHtml = await this.markdownService.unsafeMarkdownToHTML(this.markdown, true)
}
}

View File

@ -4,12 +4,13 @@ import { TableModule } from 'primeng/table'
import { NgModule } from '@angular/core'
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit'
import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module'
import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
import { SharedModerationModule } from '@app/shared/shared-moderation'
import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
import { AdminRoutingModule } from './admin-routing.module'
import { AdminComponent } from './admin.component'
import {
@ -18,6 +19,7 @@ import {
EditBasicConfigurationComponent,
EditConfigurationService,
EditCustomConfigComponent,
EditHomepageComponent,
EditInstanceInformationComponent,
EditLiveConfigurationComponent,
EditVODTranscodingComponent
@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
SharedVideoCommentModule,
SharedActorImageModule,
SharedActorImageEditModule,
SharedCustomMarkupModule,
TableModule,
SelectButtonModule,
@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
EditVODTranscodingComponent,
EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent,
EditInstanceInformationComponent
EditInstanceInformationComponent,
EditHomepageComponent
],
exports: [

View File

@ -26,22 +26,13 @@
<div class="form-group" formGroupName="instance">
<label i18n for="instanceDefaultClientRoute">Landing page</label>
<div class="peertube-select-container">
<select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control">
<option i18n value="/videos/overview">Discover videos</option>
<optgroup i18n-label label="Trending pages">
<option i18n value="/videos/trending">Default trending page</option>
<option i18n value="/videos/trending?alg=best" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('best')">Best videos</option>
<option i18n value="/videos/trending?alg=hot" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('hot')">Hot videos</option>
<option i18n value="/videos/trending?alg=most-viewed" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-viewed')">Most viewed videos</option>
<option i18n value="/videos/trending?alg=most-liked" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-liked')">Most liked videos</option>
</optgroup>
<option i18n value="/videos/recently-added">Recently added videos</option>
<option i18n value="/videos/local">Local videos</option>
</select>
</div>
<my-select-custom-value
id="instanceDefaultClientRoute"
[items]="defaultLandingPageOptions"
formControlName="defaultClientRoute"
inputType="text"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
</div>

View File

@ -1,7 +1,9 @@
import { pairwise } from 'rxjs/operators'
import { Component, Input, OnInit } from '@angular/core'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { MenuService } from '@app/core'
import { ServerConfig } from '@shared/models'
import { ConfigService } from '../shared/config.service'
@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service'
templateUrl: './edit-basic-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ]
})
export class EditBasicConfigurationComponent implements OnInit {
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
@Input() serverConfig: ServerConfig
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
constructor (
private configService: ConfigService
private configService: ConfigService,
private menuService: MenuService
) { }
ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.buildLandingPageOptions()
}
}
getVideoQuotaOptions () {
return this.configService.videoQuotaOptions
}
@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit {
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
}
buildLandingPageOptions () {
this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig)
.map(o => ({
id: o.path,
label: o.label,
description: o.path
}))
}
private checkSignupField () {
const signupControl = this.form.get('signup.enabled')

View File

@ -3,8 +3,16 @@
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
<ng-container ngbNavItem="instance-homepage">
<a ngbNavLink i18n>Homepage</a>
<ng-template ngbNavContent>
<my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
</ng-template>
</ng-container>
<ng-container ngbNavItem="instance-information">
<a ngbNavLink i18n>Instance information</a>
<a ngbNavLink i18n>Information</a>
<ng-template ngbNavContent>
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
@ -13,7 +21,7 @@
</ng-container>
<ng-container ngbNavItem="basic-configuration">
<a ngbNavLink i18n>Basic configuration</a>
<a ngbNavLink i18n>Basic</a>
<ng-template ngbNavContent>
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
@ -40,7 +48,7 @@
</ng-container>
<ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced configuration</a>
<a ngbNavLink i18n>Advanced</a>
<ng-template ngbNavContent>
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">

View File

@ -1,4 +1,5 @@
import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, OnInit } from '@angular/core'
@ -24,9 +25,14 @@ import {
} from '@app/shared/form-validators/custom-config-validators'
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { CustomConfig, ServerConfig } from '@shared/models'
import { CustomPageService } from '@app/shared/shared-main/custom-page'
import { CustomConfig, CustomPage, ServerConfig } from '@shared/models'
import { EditConfigurationService } from './edit-configuration.service'
type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage
}
@Component({
selector: 'my-edit-custom-config',
templateUrl: './edit-custom-config.component.html',
@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service'
export class EditCustomConfigComponent extends FormReactive implements OnInit {
activeNav: string
customConfig: CustomConfig
customConfig: ComponentCustomConfig
serverConfig: ServerConfig
homepage: CustomPage
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
protected formValidatorService: FormValidatorService,
private notifier: Notifier,
private configService: ConfigService,
private customPage: CustomPageService,
private serverService: ServerService,
private editConfigurationService: EditConfigurationService
) {
@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig()
.subscribe(config => {
this.serverConfig = config
})
.subscribe(config => this.serverConfig = config)
const formGroupData: { [key in keyof CustomConfig ]: any } = {
const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
disableLocalSearch: null,
isDefaultSearch: null
}
},
instanceCustomHomepage: {
content: null
}
}
@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
}
async formValidated () {
const value: CustomConfig = this.form.getRawValue()
const value: ComponentCustomConfig = this.form.getRawValue()
this.configService.updateCustomConfig(value)
forkJoin([
this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
])
.subscribe(
res => {
this.customConfig = res
([ resConfig ]) => {
const instanceCustomHomepage = {
content: value.instanceCustomHomepage.content
}
this.customConfig = { ...resConfig, instanceCustomHomepage }
// Reload general configuration
this.serverService.resetConfig()
.subscribe(config => this.serverConfig = config)
this.updateForm()
@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
}
private loadConfigAndUpdateForm () {
this.configService.getCustomConfig()
.subscribe(config => {
this.customConfig = config
forkJoin([
this.configService.getCustomConfig(),
this.customPage.getInstanceHomepage()
])
.subscribe(([ config, homepage ]) => {
this.customConfig = { ...config, instanceCustomHomepage: homepage }
this.updateForm()
// Force form validation

View File

@ -0,0 +1,28 @@
<ng-container [formGroup]="form">
<ng-container formGroupName="instanceCustomHomepage">
<div class="form-row mt-5"> <!-- homepage grid -->
<div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">INSTANCE HOMEPAGE</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
<div class="form-group">
<label i18n for="instanceCustomHomepageContent">Homepage</label>
<my-markdown-textarea
name="instanceCustomHomepageContent" formControlName="content" textareaMaxWidth="90%" textareaHeight="300px"
[customMarkdownRenderer]="customMarkdownRenderer"
[classes]="{ 'input-error': formErrors['instanceCustomHomepage.content'] }"
></my-markdown-textarea>
<div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error">{{ formErrors.instanceCustomHomepage.content }}</div>
</div>
</div>
</div>
</ng-container>
</ng-container>

View File

@ -0,0 +1,25 @@
import { Component, Input, OnInit } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { CustomMarkupService } from '@app/shared/shared-custom-markup'
@Component({
selector: 'my-edit-homepage',
templateUrl: './edit-homepage.component.html',
styleUrls: [ './edit-custom-config.component.scss' ]
})
export class EditHomepageComponent implements OnInit {
@Input() form: FormGroup
@Input() formErrors: any
customMarkdownRenderer: (text: string) => Promise<HTMLElement>
constructor (private customMarkup: CustomMarkupService) {
}
ngOnInit () {
this.customMarkdownRenderer = async (text: string) => {
return this.customMarkup.buildElement(text)
}
}
}

View File

@ -2,6 +2,7 @@ export * from './edit-advanced-configuration.component'
export * from './edit-basic-configuration.component'
export * from './edit-configuration.service'
export * from './edit-custom-config.component'
export * from './edit-homepage.component'
export * from './edit-instance-information.component'
export * from './edit-live-configuration.component'
export * from './edit-vod-transcoding.component'

View File

@ -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 {}

View File

@ -0,0 +1,4 @@
<div class="root margin-content">
<div #contentWrapper></div>
</div>

View File

@ -0,0 +1,3 @@
.root {
padding-top: 20px;
}

View File

@ -0,0 +1,26 @@
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { CustomMarkupService } from '@app/shared/shared-custom-markup'
import { CustomPageService } from '@app/shared/shared-main/custom-page'
@Component({
templateUrl: './home.component.html',
styleUrls: [ './home.component.scss' ]
})
export class HomeComponent implements OnInit {
@ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement>
constructor (
private customMarkupService: CustomMarkupService,
private customPageService: CustomPageService
) { }
async ngOnInit () {
this.customPageService.getInstanceHomepage()
.subscribe(async ({ content }) => {
const element = await this.customMarkupService.buildElement(content)
this.contentWrapper.nativeElement.appendChild(element)
})
}
}

View File

@ -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 { }

View File

@ -0,0 +1,3 @@
export * from './home-routing.module'
export * from './home.component'
export * from './home.module'

View File

@ -161,7 +161,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
// Before HTML rendering restore line feed for markdown list compatibility
const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html)
this.newParentComments = this.parentComments.concat([ this.comment ])
if (this.comment.account) {

View File

@ -509,7 +509,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private async setVideoDescriptionHTML () {
const html = await this.markdownService.textMarkdownToHTML(this.video.description)
this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html)
}
private setVideoLikesBarTooltipText () {

View File

@ -13,6 +13,10 @@ const routes: Routes = [
canDeactivate: [ MenuGuards.open() ],
loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
},
{
path: 'home',
loadChildren: () => import('./+home/home.module').then(m => m.HomeModule)
},
{
path: 'my-account',
loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule)

View File

@ -231,7 +231,7 @@ export class AppComponent implements OnInit, AfterViewInit {
}
this.broadcastMessage = {
message: await this.markdownService.completeMarkdownToHTML(messageConfig.message),
message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true),
dismissable: messageConfig.dismissable,
class: classes[messageConfig.level]
}

View File

@ -1,8 +1,19 @@
import { fromEvent } from 'rxjs'
import { debounceTime } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { GlobalIconName } from '@app/shared/shared-icons'
import { sortObjectComparator } from '@shared/core-utils/miscs/miscs'
import { ServerConfig } from '@shared/models/server'
import { ScreenService } from '../wrappers'
export type MenuLink = {
icon: GlobalIconName
label: string
menuLabel: string
path: string
priority: number
}
@Injectable()
export class MenuService {
isMenuDisplayed = true
@ -48,6 +59,53 @@ export class MenuService {
this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
}
buildCommonLinks (config: ServerConfig) {
let entries: MenuLink[] = [
{
icon: 'globe' as 'globe',
label: $localize`Discover videos`,
menuLabel: $localize`Discover`,
path: '/videos/overview',
priority: 150
},
{
icon: 'trending' as 'trending',
label: $localize`Trending videos`,
menuLabel: $localize`Trending`,
path: '/videos/trending',
priority: 140
},
{
icon: 'recently-added' as 'recently-added',
label: $localize`Recently added videos`,
menuLabel: $localize`Recently added`,
path: '/videos/recently-added',
priority: 130
},
{
icon: 'octagon' as 'octagon',
label: $localize`Local videos`,
menuLabel: $localize`Local videos`,
path: '/videos/local',
priority: 120
}
]
if (config.homepage.enabled) {
entries.push({
icon: 'home' as 'home',
label: $localize`Home`,
menuLabel: $localize`Home`,
path: '/home',
priority: 160
})
}
entries = entries.sort(sortObjectComparator('priority', 'desc'))
return entries
}
private handleWindowResize () {
// On touch screens, do not handle window resize event since opened menu is handled with a content overlay
if (this.screenService.isInTouchScreen()) return

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'
import { LinkifierService } from './linkifier.service'
import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html'
import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html'
@Injectable()
export class HtmlRendererService {
@ -20,7 +20,7 @@ export class HtmlRendererService {
})
}
async toSafeHtml (text: string) {
async toSafeHtml (text: string, additionalAllowedTags: string[] = []) {
const [ html ] = await Promise.all([
// Convert possible markdown to html
this.linkifier.linkify(text),
@ -28,7 +28,11 @@ export class HtmlRendererService {
this.loadSanitizeHtml()
])
return this.sanitizeHtml(html, SANITIZE_OPTIONS)
const options = additionalAllowedTags.length !== 0
? getCustomMarkupSanitizeOptions(additionalAllowedTags)
: getSanitizeOptions()
return this.sanitizeHtml(html, options)
}
private async loadSanitizeHtml () {

View File

@ -17,12 +17,15 @@ type MarkdownParsers = {
enhancedMarkdownIt: MarkdownIt
enhancedWithHTMLMarkdownIt: MarkdownIt
completeMarkdownIt: MarkdownIt
unsafeMarkdownIt: MarkdownIt
customPageMarkdownIt: MarkdownIt
}
type MarkdownConfig = {
rules: string[]
html: boolean
breaks: boolean
escape?: boolean
}
@ -35,18 +38,24 @@ export class MarkdownService {
private markdownParsers: MarkdownParsers = {
textMarkdownIt: null,
textWithHTMLMarkdownIt: null,
enhancedMarkdownIt: null,
enhancedWithHTMLMarkdownIt: null,
completeMarkdownIt: null
unsafeMarkdownIt: null,
customPageMarkdownIt: null
}
private parsersConfig: MarkdownParserConfigs = {
textMarkdownIt: { rules: TEXT_RULES, html: false },
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true },
textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false },
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true },
enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false },
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true },
enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false },
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, breaks: true, html: true, escape: true },
completeMarkdownIt: { rules: COMPLETE_RULES, html: true }
unsafeMarkdownIt: { rules: COMPLETE_RULES, breaks: true, html: true, escape: false },
customPageMarkdownIt: { rules: COMPLETE_RULES, breaks: false, html: true, escape: true }
}
private emojiModule: any
@ -54,22 +63,26 @@ export class MarkdownService {
constructor (private htmlRenderer: HtmlRendererService) {}
textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown, withEmoji)
if (withHtml) return this.render({ name: 'textWithHTMLMarkdownIt', markdown, withEmoji })
return this.render('textMarkdownIt', markdown, withEmoji)
return this.render({ name: 'textMarkdownIt', markdown, withEmoji })
}
enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown, withEmoji)
if (withHtml) return this.render({ name: 'enhancedWithHTMLMarkdownIt', markdown, withEmoji })
return this.render('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)

View File

@ -173,6 +173,9 @@ export class ServerService {
disableLocalSearch: false,
isDefaultSearch: false
}
},
homepage: {
enabled: false
}
}
@ -198,9 +201,7 @@ export class ServerService {
this.configReset = true
// Notify config update
this.getConfig().subscribe(() => {
// empty, to fire a reset config event
})
return this.getConfig()
}
getConfig () {

View File

@ -123,24 +123,9 @@
<div class="on-instance">
<div i18n class="block-title">ON {{instanceName}}</div>
<a class="menu-link" routerLink="/videos/overview" routerLinkActive="active">
<my-global-icon iconName="globe" aria-hidden="true"></my-global-icon>
<ng-container i18n>Discover</ng-container>
</a>
<a class="menu-link" routerLink="/videos/trending" routerLinkActive="active">
<my-global-icon iconName="trending" aria-hidden="true"></my-global-icon>
<ng-container i18n>Trending</ng-container>
</a>
<a class="menu-link" routerLink="/videos/recently-added" routerLinkActive="active">
<my-global-icon iconName="recently-added" aria-hidden="true"></my-global-icon>
<ng-container i18n>Recently added</ng-container>
</a>
<a class="menu-link" routerLink="/videos/local" routerLinkActive="active">
<my-global-icon iconName="home" aria-hidden="true"></my-global-icon>
<ng-container i18n>Local videos</ng-container>
<a class="menu-link" *ngFor="let commonLink of commonMenuLinks" [routerLink]="commonLink.path" routerLinkActive="active">
<my-global-icon [iconName]="commonLink.icon" aria-hidden="true"></my-global-icon>
<ng-container>{{ commonLink.menuLabel }}</ng-container>
</a>
</div>
</div>

View File

@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators'
import { ViewportScroller } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
import {
AuthService,
AuthStatus,
AuthUser,
MenuLink,
MenuService,
RedirectService,
ScreenService,
ServerService,
UserService
} from '@app/core'
import { scrollToTop } from '@app/helpers'
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
@ -35,6 +45,8 @@ export class MenuComponent implements OnInit {
currentInterfaceLanguage: string
commonMenuLinks: MenuLink[] = []
private languages: VideoConstant<string>[] = []
private serverConfig: ServerConfig
private routesPerRight: { [role in UserRight]?: string } = {
@ -80,7 +92,10 @@ export class MenuComponent implements OnInit {
ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig()
.subscribe(config => this.serverConfig = config)
.subscribe(config => {
this.serverConfig = config
this.buildMenuLinks()
})
this.isLoggedIn = this.authService.isLoggedIn()
if (this.isLoggedIn === true) {
@ -241,6 +256,10 @@ export class MenuComponent implements OnInit {
}
}
private buildMenuLinks () {
this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig)
}
private buildUserLanguages () {
if (!this.user) {
this.videoLanguages = []

View File

@ -0,0 +1,8 @@
<div *ngIf="channel" class="channel">
<my-actor-avatar [channel]="channel" size="34"></my-actor-avatar>
<div class="display-name">{{ channel.displayName }}</div>
<div class="username">{{ channel.name }}</div>
<div class="description">{{ channel.description }}</div>
</div>

View File

@ -0,0 +1,9 @@
@import '_variables';
@import '_mixins';
.channel {
border-radius: 15px;
padding: 10px;
width: min-content;
border: 1px solid pvar(--mainColor);
}

View File

@ -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)
}
}

View File

@ -0,0 +1,136 @@
import { ComponentRef, Injectable } from '@angular/core'
import { MarkdownService } from '@app/core'
import {
ChannelMiniatureMarkupData,
EmbedMarkupData,
PlaylistMiniatureMarkupData,
VideoMiniatureMarkupData,
VideosListMarkupData
} from '@shared/models'
import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
import { DynamicElementService } from './dynamic-element.service'
import { EmbedMarkupComponent } from './embed-markup.component'
import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
import { VideosListMarkupComponent } from './videos-list-markup.component'
type BuilderFunction = (el: HTMLElement) => ComponentRef<any>
@Injectable()
export class CustomMarkupService {
private builders: { [ selector: string ]: BuilderFunction } = {
'peertube-video-embed': el => this.embedBuilder(el, 'video'),
'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'),
'peertube-video-miniature': el => this.videoMiniatureBuilder(el),
'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el),
'peertube-channel-miniature': el => this.channelMiniatureBuilder(el),
'peertube-videos-list': el => this.videosListBuilder(el)
}
constructor (
private dynamicElementService: DynamicElementService,
private markdown: MarkdownService
) { }
async buildElement (text: string) {
const html = await this.markdown.customPageMarkdownToHTML(text, this.getSupportedTags())
const rootElement = document.createElement('div')
rootElement.innerHTML = html
for (const selector of this.getSupportedTags()) {
rootElement.querySelectorAll(selector)
.forEach((e: HTMLElement) => {
try {
const component = this.execBuilder(selector, e)
this.dynamicElementService.injectElement(e, component)
} catch (err) {
console.error('Cannot inject component %s.', selector, err)
}
})
}
return rootElement
}
private getSupportedTags () {
return Object.keys(this.builders)
}
private execBuilder (selector: string, el: HTMLElement) {
return this.builders[selector](el)
}
private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') {
const data = el.dataset as EmbedMarkupData
const component = this.dynamicElementService.createElement(EmbedMarkupComponent)
this.dynamicElementService.setModel(component, { uuid: data.uuid, type })
return component
}
private videoMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as VideoMiniatureMarkupData
const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent)
this.dynamicElementService.setModel(component, { uuid: data.uuid })
return component
}
private playlistMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as PlaylistMiniatureMarkupData
const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent)
this.dynamicElementService.setModel(component, { uuid: data.uuid })
return component
}
private channelMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as ChannelMiniatureMarkupData
const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent)
this.dynamicElementService.setModel(component, { name: data.name })
return component
}
private videosListBuilder (el: HTMLElement) {
const data = el.dataset as VideosListMarkupData
const component = this.dynamicElementService.createElement(VideosListMarkupComponent)
const model = {
title: data.title,
description: data.description,
sort: data.sort,
categoryOneOf: this.buildArrayNumber(data.categoryOneOf),
languageOneOf: this.buildArrayString(data.languageOneOf),
count: this.buildNumber(data.count) || 10
}
this.dynamicElementService.setModel(component, model)
return component
}
private buildNumber (value: string) {
if (!value) return undefined
return parseInt(value, 10)
}
private buildArrayNumber (value: string) {
if (!value) return undefined
return value.split(',').map(v => parseInt(v, 10))
}
private buildArrayString (value: string) {
if (!value) return undefined
return value.split(',')
}
}

View File

@ -0,0 +1,57 @@
import {
ApplicationRef,
ComponentFactoryResolver,
ComponentRef,
EmbeddedViewRef,
Injectable,
Injector,
OnChanges,
SimpleChange,
SimpleChanges,
Type
} from '@angular/core'
@Injectable()
export class DynamicElementService {
constructor (
private injector: Injector,
private applicationRef: ApplicationRef,
private componentFactoryResolver: ComponentFactoryResolver
) { }
createElement <T> (ofComponent: Type<T>) {
const div = document.createElement('div')
const component = this.componentFactoryResolver.resolveComponentFactory(ofComponent)
.create(this.injector, [], div)
return component
}
injectElement <T> (wrapper: HTMLElement, componentRef: ComponentRef<T>) {
const hostView = componentRef.hostView as EmbeddedViewRef<any>
this.applicationRef.attachView(hostView)
wrapper.appendChild(hostView.rootNodes[0])
}
setModel <T> (componentRef: ComponentRef<T>, attributes: Partial<T>) {
const changes: SimpleChanges = {}
for (const key of Object.keys(attributes)) {
const previousValue = componentRef.instance[key]
const newValue = attributes[key]
componentRef.instance[key] = newValue
changes[key] = new SimpleChange(previousValue, newValue, previousValue === undefined)
}
const component = componentRef.instance
if (typeof (component as unknown as OnChanges).ngOnChanges === 'function') {
(component as unknown as OnChanges).ngOnChanges(changes)
}
componentRef.changeDetectorRef.detectChanges()
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,3 @@
export * from './custom-markup.service'
export * from './dynamic-element.service'
export * from './shared-custom-markup.module'

View File

@ -0,0 +1,2 @@
<my-video-playlist-miniature *ngIf="playlist" [playlist]="playlist">
</my-video-playlist-miniature>

View File

@ -0,0 +1,7 @@
@import '_variables';
@import '_mixins';
my-video-playlist-miniature {
display: inline-block;
width: $video-thumbnail-width;
}

View File

@ -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)
}
}

View File

@ -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 { }

View File

@ -0,0 +1,6 @@
<my-video-miniature
*ngIf="video"
[video]="video" [user]="getUser()" [displayAsRow]="false"
[displayVideoActions]="false" [displayOptions]="displayOptions"
>
</my-video-miniature>

View File

@ -0,0 +1,7 @@
@import '_variables';
@import '_mixins';
my-video-miniature {
display: inline-block;
width: $video-thumbnail-width;
}

View File

@ -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)
}
}

View File

@ -0,0 +1,13 @@
<div class="root">
<h4 *ngIf="title">{{ title }}</h4>
<div *ngIf="description" class="description">{{ description }}</div>
<div class="videos">
<my-video-miniature
*ngFor="let video of videos"
[video]="video" [user]="getUser()" [displayAsRow]="false"
[displayVideoActions]="false" [displayOptions]="displayOptions"
>
</my-video-miniature>
</div>
</div>

View File

@ -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;
}

View File

@ -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)
}
}

View File

@ -19,6 +19,7 @@
<a ngbNavLink i18n>Complete preview</a>
<ng-template ngbNavContent>
<div #previewElement></div>
<div [innerHTML]="previewHTML"></div>
</ng-template>
</ng-container>

View File

@ -1,9 +1,10 @@
import { ViewportScroller } from '@angular/common'
import truncate from 'lodash-es/truncate'
import { Subject } from 'rxjs'
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
import { ViewportScroller } from '@angular/common'
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { SafeHtml } from '@angular/platform-browser'
import { MarkdownService, ScreenService } from '@app/core'
@Component({
@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core'
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
@Input() content = ''
@Input() classes: string[] | { [klass: string]: any[] | any } = []
@Input() textareaMaxWidth = '100%'
@Input() textareaHeight = '150px'
@Input() truncate: number
@Input() markdownType: 'text' | 'enhanced' = 'text'
@Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
@Input() markdownVideo = false
@Input() name = 'description'
@ViewChild('textarea') textareaElement: ElementRef
@ViewChild('previewElement') previewElement: ElementRef
truncatedPreviewHTML: SafeHtml | string = ''
previewHTML: SafeHtml | string = ''
truncatedPreviewHTML = ''
previewHTML = ''
isMaximized = false
maximizeInText = $localize`Maximize editor`
@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
}
private async markdownRender (text: string) {
const html = this.markdownType === 'text' ?
await this.markdownService.textMarkdownToHTML(text) :
await this.markdownService.enhancedMarkdownToHTML(text)
let html: string
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
}
}

View File

@ -72,6 +72,7 @@ const icons = {
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
'octagon': require('!!raw-loader?!../../../assets/images/feather/octagon.svg').default,
'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default
}

View File

@ -0,0 +1,38 @@
import { of } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { CustomPage } from '@shared/models'
import { environment } from '../../../../environments/environment'
@Injectable()
export class CustomPageService {
static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance'
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor
) { }
getInstanceHomepage () {
return this.authHttp.get<CustomPage>(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL)
.pipe(
catchError(err => {
if (err.status === 404) {
return of({ content: '' })
}
this.restExtractor.handleError(err)
})
)
}
updateInstanceHomepage (content: string) {
return this.authHttp.put(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL, { content })
.pipe(
map(this.restExtractor.extractDataBool),
catchError(err => this.restExtractor.handleError(err))
)
}
}

View File

@ -0,0 +1 @@
export * from './custom-page.service'

View File

@ -29,6 +29,7 @@ import {
} from './angular'
import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
import { CustomPageService } from './custom-page'
import { DateToggleComponent } from './date'
import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders'
@ -171,7 +172,9 @@ import { VideoChannelService } from './video-channel'
VideoCaptionService,
VideoChannelService
VideoChannelService,
CustomPageService
]
})
export class SharedMainModule { }

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-octagon">
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon>
</svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@ -95,7 +95,7 @@ function buildVideoLink (options: {
function buildPlaylistLink (options: {
baseUrl?: string
playlistPosition: number
playlistPosition?: number
}) {
const { baseUrl } = options

View File

@ -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

View File

@ -1,8 +1,8 @@
import { ServerConfigManager } from '@server/lib/server-config-manager'
import * as express from 'express'
import { remove, writeJSON } from 'fs-extra'
import { snakeCase } from 'lodash'
import validator from 'validator'
import { getServerConfig } from '@server/lib/config'
import { UserRight } from '../../../shared'
import { About } from '../../../shared/models/server/about.model'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@ -43,7 +43,7 @@ configRouter.delete('/custom',
)
async function getConfig (req: express.Request, res: express.Response) {
const json = await getServerConfig(req.ip)
const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
return res.json(json)
}

View File

@ -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)
}

View File

@ -8,6 +8,7 @@ import { abuseRouter } from './abuse'
import { accountsRouter } from './accounts'
import { bulkRouter } from './bulk'
import { configRouter } from './config'
import { customPageRouter } from './custom-page'
import { jobsRouter } from './jobs'
import { oauthClientsRouter } from './oauth-clients'
import { overviewsRouter } from './overviews'
@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/search', searchRouter)
apiRouter.use('/overviews', overviewsRouter)
apiRouter.use('/plugins', pluginRouter)
apiRouter.use('/custom-pages', customPageRouter)
apiRouter.use('/ping', pong)
apiRouter.use('/*', badRequest)

View File

@ -3,7 +3,7 @@ import { move, readFile } from 'fs-extra'
import * as magnetUtil from 'magnet-uri'
import * as parseTorrent from 'parse-torrent'
import { join } from 'path'
import { getEnabledResolutions } from '@server/lib/config'
import { ServerConfigManager } from '@server/lib/server-config-manager'
import { setVideoTags } from '@server/lib/video'
import { FilteredModelAttributes } from '@server/types'
import {
@ -134,7 +134,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
const targetUrl = body.targetUrl
const user = res.locals.oauth.token.User
const youtubeDL = new YoutubeDL(targetUrl, getEnabledResolutions('vod'))
const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
// Get video infos
let youtubeDLInfo: YoutubeDLInfo

View File

@ -2,7 +2,7 @@ import * as cors from 'cors'
import * as express from 'express'
import { join } from 'path'
import { serveIndexHTML } from '@server/lib/client-html'
import { getEnabledResolutions, getRegisteredPlugins, getRegisteredThemes } from '@server/lib/config'
import { ServerConfigManager } from '@server/lib/server-config-manager'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
import { root } from '../helpers/core-utils'
@ -203,10 +203,10 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
}
},
plugin: {
registered: getRegisteredPlugins()
registered: ServerConfigManager.Instance.getRegisteredPlugins()
},
theme: {
registered: getRegisteredThemes(),
registered: ServerConfigManager.Instance.getRegisteredThemes(),
default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
},
email: {
@ -222,13 +222,13 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
webtorrent: {
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
},
enabledResolutions: getEnabledResolutions('vod')
enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
},
live: {
enabled: CONFIG.LIVE.ENABLED,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: getEnabledResolutions('live')
enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
}
},
import: {

View File

@ -1,4 +1,6 @@
import { SANITIZE_OPTIONS, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
const sanitizeOptions = getSanitizeOptions()
const sanitizeHtml = require('sanitize-html')
const markdownItEmoji = require('markdown-it-emoji/light')
@ -18,7 +20,7 @@ const toSafeHtml = text => {
const html = markdownIt.render(textWithLineFeed)
// Convert to safe Html
return sanitizeHtml(html, SANITIZE_OPTIONS)
return sanitizeHtml(html, sanitizeOptions)
}
const mdToPlainText = text => {
@ -28,7 +30,7 @@ const mdToPlainText = text => {
const html = markdownIt.render(text)
// Convert to safe Html
const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS)
const safeHtml = sanitizeHtml(html, sanitizeOptions)
return safeHtml.replace(/<[^>]+>/g, '')
.replace(/\n$/, '')

View File

@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 645
const LAST_MIGRATION_VERSION = 650
// ---------------------------------------------------------------------------

View File

@ -44,6 +44,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
import { VideoTagModel } from '../models/video/video-tag'
import { VideoViewModel } from '../models/video/video-view'
import { CONFIG } from './config'
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) {
ThumbnailModel,
TrackerModel,
VideoTrackerModel,
PluginModel
PluginModel,
ActorCustomPageModel
])
// Check extensions exist in the database

View File

@ -0,0 +1,33 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
{
const query = `
CREATE TABLE IF NOT EXISTS "actorCustomPage" (
"id" serial,
"content" TEXT,
"type" varchar(255) NOT NULL,
"actorId" integer NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"createdAt" timestamp WITH time zone NOT NULL,
"updatedAt" timestamp WITH time zone NOT NULL,
PRIMARY KEY ("id")
);
`
await utils.sequelize.query(query)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -26,7 +26,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
import { getActivityStreamDuration } from '../models/video/video-format-utils'
import { VideoPlaylistModel } from '../models/video/video-playlist'
import { MAccountActor, MChannelActor } from '../types/models'
import { getHTMLServerConfig } from './config'
import { ServerConfigManager } from './server-config-manager'
type Tags = {
ogType: string
@ -211,7 +211,7 @@ class ClientHtml {
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
const buffer = await readFile(path)
const serverConfig = await getHTMLServerConfig()
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
let html = buffer.toString()
html = await ClientHtml.addAsyncPluginCSS(html)
@ -280,7 +280,7 @@ class ClientHtml {
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
const buffer = await readFile(path)
const serverConfig = await getHTMLServerConfig()
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
let html = buffer.toString()

View File

@ -1,274 +0,0 @@
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup'
import { getServerCommit } from '@server/helpers/utils'
import { CONFIG, isEmailEnabled } from '@server/initializers/config'
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
import { Hooks } from './plugins/hooks'
import { PluginManager } from './plugins/plugin-manager'
import { getThemeOrDefault } from './plugins/theme-utils'
import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
async function getServerConfig (ip?: string): Promise<ServerConfig> {
const { allowed } = await Hooks.wrapPromiseFun(
isSignupAllowed,
{
ip
},
'filter:api.user.signup.allowed.result'
)
const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
const signup = {
allowed,
allowedForCurrentIP,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
}
const htmlConfig = await getHTMLServerConfig()
return { ...htmlConfig, signup }
}
// Config injected in HTML
let serverCommit: string
async function getHTMLServerConfig (): Promise<HTMLServerConfig> {
if (serverCommit === undefined) serverCommit = await getServerCommit()
const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
return {
instance: {
name: CONFIG.INSTANCE.NAME,
shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
isNSFW: CONFIG.INSTANCE.IS_NSFW,
defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
customizations: {
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
}
},
search: {
remoteUri: {
users: CONFIG.SEARCH.REMOTE_URI.USERS,
anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
},
searchIndex: {
enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
url: CONFIG.SEARCH.SEARCH_INDEX.URL,
disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
}
},
plugin: {
registered: getRegisteredPlugins(),
registeredExternalAuths: getExternalAuthsPlugins(),
registeredIdAndPassAuths: getIdAndPassAuthPlugins()
},
theme: {
registered: getRegisteredThemes(),
default: defaultTheme
},
email: {
enabled: isEmailEnabled()
},
contactForm: {
enabled: CONFIG.CONTACT_FORM.ENABLED
},
serverVersion: PEERTUBE_VERSION,
serverCommit,
transcoding: {
hls: {
enabled: CONFIG.TRANSCODING.HLS.ENABLED
},
webtorrent: {
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
},
enabledResolutions: getEnabledResolutions('vod'),
profile: CONFIG.TRANSCODING.PROFILE,
availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
},
live: {
enabled: CONFIG.LIVE.ENABLED,
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
maxDuration: CONFIG.LIVE.MAX_DURATION,
maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: getEnabledResolutions('live'),
profile: CONFIG.LIVE.TRANSCODING.PROFILE,
availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
},
rtmp: {
port: CONFIG.LIVE.RTMP.PORT
}
},
import: {
videos: {
http: {
enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
},
torrent: {
enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
}
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
}
}
},
avatar: {
file: {
size: {
max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
}
},
banner: {
file: {
size: {
max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
}
},
video: {
image: {
extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
size: {
max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
}
},
file: {
extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
}
},
videoCaption: {
file: {
size: {
max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
}
},
user: {
videoQuota: CONFIG.USER.VIDEO_QUOTA,
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
},
trending: {
videos: {
intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
algorithms: {
enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
}
}
},
tracker: {
enabled: CONFIG.TRACKER.ENABLED
},
followings: {
instance: {
autoFollowIndex: {
indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
}
}
},
broadcastMessage: {
enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
level: CONFIG.BROADCAST_MESSAGE.LEVEL,
dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
}
}
}
function getRegisteredThemes () {
return PluginManager.Instance.getRegisteredThemes()
.map(t => ({
name: t.name,
version: t.version,
description: t.description,
css: t.css,
clientScripts: t.clientScripts
}))
}
function getRegisteredPlugins () {
return PluginManager.Instance.getRegisteredPlugins()
.map(p => ({
name: p.name,
version: p.version,
description: p.description,
clientScripts: p.clientScripts
}))
}
function getEnabledResolutions (type: 'vod' | 'live') {
const transcoding = type === 'vod'
? CONFIG.TRANSCODING
: CONFIG.LIVE.TRANSCODING
return Object.keys(transcoding.RESOLUTIONS)
.filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
.map(r => parseInt(r, 10))
}
// ---------------------------------------------------------------------------
export {
getServerConfig,
getRegisteredThemes,
getEnabledResolutions,
getRegisteredPlugins,
getHTMLServerConfig
}
// ---------------------------------------------------------------------------
function getIdAndPassAuthPlugins () {
const result: RegisteredIdAndPassAuthConfig[] = []
for (const p of PluginManager.Instance.getIdAndPassAuths()) {
for (const auth of p.idAndPassAuths) {
result.push({
npmName: p.npmName,
name: p.name,
version: p.version,
authName: auth.authName,
weight: auth.getWeight()
})
}
}
return result
}
function getExternalAuthsPlugins () {
const result: RegisteredExternalAuthConfig[] = []
for (const p of PluginManager.Instance.getExternalAuths()) {
for (const auth of p.externalAuths) {
result.push({
npmName: p.npmName,
name: p.name,
version: p.version,
authName: auth.authName,
authDisplayName: auth.authDisplayName()
})
}
}
return result
}

View File

@ -2,8 +2,10 @@ import * as Bull from 'bull'
import { move, remove, stat } from 'fs-extra'
import { extname } from 'path'
import { retryTransactionWrapper } from '@server/helpers/database-utils'
import { YoutubeDL } from '@server/helpers/youtube-dl'
import { isPostImportVideoAccepted } from '@server/lib/moderation'
import { Hooks } from '@server/lib/plugins/hooks'
import { ServerConfigManager } from '@server/lib/server-config-manager'
import { isAbleToUploadVideo } from '@server/lib/user'
import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
@ -33,8 +35,6 @@ import { MThumbnail } from '../../../types/models/video/thumbnail'
import { federateVideoIfNeeded } from '../../activitypub/videos'
import { Notifier } from '../../notifier'
import { generateVideoMiniature } from '../../thumbnail'
import { YoutubeDL } from '@server/helpers/youtube-dl'
import { getEnabledResolutions } from '@server/lib/config'
async function processVideoImport (job: Bull.Job) {
const payload = job.data as VideoImportPayload
@ -76,7 +76,7 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub
videoImportId: videoImport.id
}
const youtubeDL = new YoutubeDL(videoImport.targetUrl, getEnabledResolutions('vod'))
const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
return processFile(
() => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT),

View File

@ -15,7 +15,7 @@ import { MPlugin } from '@server/types/models'
import { PeerTubeHelpers } from '@server/types/plugins'
import { VideoBlacklistCreate } from '@shared/models'
import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
import { getServerConfig } from '../config'
import { ServerConfigManager } from '../server-config-manager'
import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
import { UserModel } from '@server/models/user/user'
@ -147,7 +147,7 @@ function buildConfigHelpers () {
},
getServerConfig () {
return getServerConfig()
return ServerConfigManager.Instance.getServerConfig()
}
}
}

View File

@ -0,0 +1,303 @@
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup'
import { getServerCommit } from '@server/helpers/utils'
import { CONFIG, isEmailEnabled } from '@server/initializers/config'
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
import { Hooks } from './plugins/hooks'
import { PluginManager } from './plugins/plugin-manager'
import { getThemeOrDefault } from './plugins/theme-utils'
import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
/**
*
* Used to send the server config to clients (using REST/API or plugins API)
* We need a singleton class to manage config state depending on external events (to build menu entries etc)
*
*/
class ServerConfigManager {
private static instance: ServerConfigManager
private serverCommit: string
private homepageEnabled = false
private constructor () {}
async init () {
const instanceHomepage = await ActorCustomPageModel.loadInstanceHomepage()
this.updateHomepageState(instanceHomepage?.content)
}
updateHomepageState (content: string) {
this.homepageEnabled = !!content
}
async getHTMLServerConfig (): Promise<HTMLServerConfig> {
if (this.serverCommit === undefined) this.serverCommit = await getServerCommit()
const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
return {
instance: {
name: CONFIG.INSTANCE.NAME,
shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
isNSFW: CONFIG.INSTANCE.IS_NSFW,
defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
customizations: {
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
}
},
search: {
remoteUri: {
users: CONFIG.SEARCH.REMOTE_URI.USERS,
anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
},
searchIndex: {
enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
url: CONFIG.SEARCH.SEARCH_INDEX.URL,
disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
}
},
plugin: {
registered: this.getRegisteredPlugins(),
registeredExternalAuths: this.getExternalAuthsPlugins(),
registeredIdAndPassAuths: this.getIdAndPassAuthPlugins()
},
theme: {
registered: this.getRegisteredThemes(),
default: defaultTheme
},
email: {
enabled: isEmailEnabled()
},
contactForm: {
enabled: CONFIG.CONTACT_FORM.ENABLED
},
serverVersion: PEERTUBE_VERSION,
serverCommit: this.serverCommit,
transcoding: {
hls: {
enabled: CONFIG.TRANSCODING.HLS.ENABLED
},
webtorrent: {
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
},
enabledResolutions: this.getEnabledResolutions('vod'),
profile: CONFIG.TRANSCODING.PROFILE,
availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
},
live: {
enabled: CONFIG.LIVE.ENABLED,
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
maxDuration: CONFIG.LIVE.MAX_DURATION,
maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: this.getEnabledResolutions('live'),
profile: CONFIG.LIVE.TRANSCODING.PROFILE,
availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
},
rtmp: {
port: CONFIG.LIVE.RTMP.PORT
}
},
import: {
videos: {
http: {
enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
},
torrent: {
enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
}
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
}
}
},
avatar: {
file: {
size: {
max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
}
},
banner: {
file: {
size: {
max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
}
},
video: {
image: {
extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
size: {
max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
}
},
file: {
extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
}
},
videoCaption: {
file: {
size: {
max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
}
},
user: {
videoQuota: CONFIG.USER.VIDEO_QUOTA,
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
},
trending: {
videos: {
intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
algorithms: {
enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
}
}
},
tracker: {
enabled: CONFIG.TRACKER.ENABLED
},
followings: {
instance: {
autoFollowIndex: {
indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
}
}
},
broadcastMessage: {
enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
level: CONFIG.BROADCAST_MESSAGE.LEVEL,
dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
},
homepage: {
enabled: this.homepageEnabled
}
}
}
async getServerConfig (ip?: string): Promise<ServerConfig> {
const { allowed } = await Hooks.wrapPromiseFun(
isSignupAllowed,
{
ip
},
'filter:api.user.signup.allowed.result'
)
const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
const signup = {
allowed,
allowedForCurrentIP,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
}
const htmlConfig = await this.getHTMLServerConfig()
return { ...htmlConfig, signup }
}
getRegisteredThemes () {
return PluginManager.Instance.getRegisteredThemes()
.map(t => ({
name: t.name,
version: t.version,
description: t.description,
css: t.css,
clientScripts: t.clientScripts
}))
}
getRegisteredPlugins () {
return PluginManager.Instance.getRegisteredPlugins()
.map(p => ({
name: p.name,
version: p.version,
description: p.description,
clientScripts: p.clientScripts
}))
}
getEnabledResolutions (type: 'vod' | 'live') {
const transcoding = type === 'vod'
? CONFIG.TRANSCODING
: CONFIG.LIVE.TRANSCODING
return Object.keys(transcoding.RESOLUTIONS)
.filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
.map(r => parseInt(r, 10))
}
private getIdAndPassAuthPlugins () {
const result: RegisteredIdAndPassAuthConfig[] = []
for (const p of PluginManager.Instance.getIdAndPassAuths()) {
for (const auth of p.idAndPassAuths) {
result.push({
npmName: p.npmName,
name: p.name,
version: p.version,
authName: auth.authName,
weight: auth.getWeight()
})
}
}
return result
}
private getExternalAuthsPlugins () {
const result: RegisteredExternalAuthConfig[] = []
for (const p of PluginManager.Instance.getExternalAuths()) {
for (const auth of p.externalAuths) {
result.push({
npmName: p.npmName,
name: p.name,
version: p.version,
authName: auth.authName,
authDisplayName: auth.authDisplayName()
})
}
}
return result
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
ServerConfigManager
}

View File

@ -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
}
}
}

View File

@ -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 ])
})
})

View File

@ -3,6 +3,7 @@ import './accounts'
import './blocklist'
import './bulk'
import './config'
import './custom-pages'
import './contact-form'
import './debug'
import './follows'

View File

@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import { HttpStatusCode } from '@shared/core-utils'
import { CustomPage, ServerConfig } from '@shared/models'
import {
cleanupTests,
flushAndRunServer,
getConfig,
getInstanceHomepage,
killallServers,
reRunServer,
ServerInfo,
setAccessTokensToServers,
updateInstanceHomepage
} from '../../../../shared/extra-utils/index'
const expect = chai.expect
async function getHomepageState (server: ServerInfo) {
const res = await getConfig(server.url)
const config = res.body as ServerConfig
return config.homepage.enabled
}
describe('Test instance homepage actions', function () {
let server: ServerInfo
before(async function () {
this.timeout(30000)
server = await flushAndRunServer(1)
await setAccessTokensToServers([ server ])
})
it('Should not have a homepage', async function () {
const state = await getHomepageState(server)
expect(state).to.be.false
await getInstanceHomepage(server.url, HttpStatusCode.NOT_FOUND_404)
})
it('Should set a homepage', async function () {
await updateInstanceHomepage(server.url, server.accessToken, '<picsou-magazine></picsou-magazine>')
const res = await getInstanceHomepage(server.url)
const page: CustomPage = res.body
expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
const state = await getHomepageState(server)
expect(state).to.be.true
})
it('Should have the same homepage after a restart', async function () {
this.timeout(30000)
killallServers([ server ])
await reRunServer(server)
const res = await getInstanceHomepage(server.url)
const page: CustomPage = res.body
expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
const state = await getHomepageState(server)
expect(state).to.be.true
})
it('Should empty the homepage', async function () {
await updateInstanceHomepage(server.url, server.accessToken, '')
const res = await getInstanceHomepage(server.url)
const page: CustomPage = res.body
expect(page.content).to.be.empty
const state = await getHomepageState(server)
expect(state).to.be.false
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -5,6 +5,7 @@ import './email'
import './follow-constraints'
import './follows'
import './follows-moderation'
import './homepage'
import './handle-down'
import './jobs'
import './logs'

View File

@ -0,0 +1,4 @@
import { ActorCustomPageModel } from '../../../models/account/actor-custom-page'
export type MActorCustomPage = Omit<ActorCustomPageModel, 'Actor'>

View File

@ -1,2 +1,3 @@
export * from './account'
export * from './actor-custom-page'
export * from './account-blocklist'

View File

@ -28,9 +28,24 @@ function isCatchable (value: any) {
return value && typeof value.catch === 'function'
}
function sortObjectComparator (key: string, order: 'asc' | 'desc') {
return (a: any, b: any) => {
if (a[key] < b[key]) {
return order === 'asc' ? -1 : 1
}
if (a[key] > b[key]) {
return order === 'asc' ? 1 : -1
}
return 0
}
}
export {
randomInt,
compareSemVer,
isPromise,
isCatchable
isCatchable,
sortObjectComparator
}

View File

@ -1,25 +1,45 @@
export const SANITIZE_OPTIONS = {
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
allowedSchemes: [ 'http', 'https' ],
allowedAttributes: {
a: [ 'href', 'class', 'target', 'rel' ]
},
transformTags: {
a: (tagName: string, attribs: any) => {
let rel = 'noopener noreferrer'
if (attribs.rel === 'me') rel += ' me'
export function getSanitizeOptions () {
return {
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
allowedSchemes: [ 'http', 'https' ],
allowedAttributes: {
'a': [ 'href', 'class', 'target', 'rel' ],
'*': [ 'data-*' ]
},
transformTags: {
a: (tagName: string, attribs: any) => {
let rel = 'noopener noreferrer'
if (attribs.rel === 'me') rel += ' me'
return {
tagName,
attribs: Object.assign(attribs, {
target: '_blank',
rel
})
return {
tagName,
attribs: Object.assign(attribs, {
target: '_blank',
rel
})
}
}
}
}
}
export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) {
const base = getSanitizeOptions()
return {
allowedTags: [
...base.allowedTags,
...additionalAllowedTags,
'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
],
allowedSchemes: base.allowedSchemes,
allowedAttributes: {
...base.allowedAttributes,
'*': [ 'data-*', 'style' ]
}
}
}
// Thanks: https://stackoverflow.com/a/12034334
export function escapeHTML (stringParam: string) {
if (!stringParam) return ''

View File

@ -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
}

View File

@ -2,6 +2,8 @@ export * from './bulk/bulk'
export * from './cli/cli'
export * from './custom-pages/custom-pages'
export * from './feeds/feeds'
export * from './mock-servers/mock-instances-index'

View File

@ -0,0 +1,3 @@
export interface CustomPage {
content: string
}

View File

@ -2,4 +2,5 @@ export * from './account.model'
export * from './actor-image.model'
export * from './actor-image.type'
export * from './actor.model'
export * from './custom-page.model'
export * from './follow.model'

View File

@ -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
}

View File

@ -0,0 +1 @@
export * from './custom-markup-data.model'

View File

@ -1,6 +1,7 @@
export * from './activitypub'
export * from './actors'
export * from './moderation'
export * from './custom-markup'
export * from './bulk'
export * from './redundancy'
export * from './users'

View File

@ -214,6 +214,10 @@ export interface ServerConfig {
level: BroadcastMessageLevel
dismissable: boolean
}
homepage: {
enabled: boolean
}
}
export type HTMLServerConfig = Omit<ServerConfig, 'signup'>

View File

@ -16,6 +16,7 @@ export const enum UserRight {
MANAGE_JOBS,
MANAGE_CONFIGURATION,
MANAGE_INSTANCE_CUSTOM_PAGE,
MANAGE_ACCOUNTS_BLOCKLIST,
MANAGE_SERVERS_BLOCKLIST,

View File

@ -247,6 +247,8 @@ tags:
Administrators can also enable the use of a remote search system, indexing
videos and channels not could be not federated by the instance.
- name: Homepage
description: Get and update the custom homepage
- name: Video Mirroring
description: |
PeerTube instances can mirror videos from one another, and help distribute some videos.
@ -281,6 +283,9 @@ x-tagGroups:
- name: Search
tags:
- Search
- name: Custom pages
tags:
- Homepage
- name: Moderation
tags:
- Abuses
@ -477,6 +482,40 @@ paths:
'200':
description: successful operation
/custom-pages/homepage/instance:
get:
summary: Get instance custom homepage
tags:
- Homepage
responses:
'404':
description: No homepage set
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/CustomHomepage'
put:
summary: Set instance custom homepage
tags:
- Homepage
security:
- OAuth2:
- admin
requestBody:
content:
application/json:
schema:
type: object
properties:
content:
type: string
description: content of the homepage, that will be injected in the client
responses:
'204':
description: successful operation
/jobs/{state}:
get:
summary: List instance jobs
@ -5740,6 +5779,12 @@ components:
indexUrl:
type: string
format: url
homepage:
type: object
properties:
enabled:
type: boolean
ServerConfigAbout:
properties:
instance:
@ -5930,6 +5975,12 @@ components:
type: boolean
manualApproval:
type: boolean
CustomHomepage:
properties:
content:
type: string
Follow:
properties:
id: