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) { } constructor (private markdownService: MarkdownService) { }
async ngOnInit () { 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 { NgModule } from '@angular/core'
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit' 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 { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons' import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main' import { SharedMainModule } from '@app/shared/shared-main'
import { SharedModerationModule } from '@app/shared/shared-moderation' import { SharedModerationModule } from '@app/shared/shared-moderation'
import { SharedVideoCommentModule } from '@app/shared/shared-video-comment' 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 { AdminRoutingModule } from './admin-routing.module'
import { AdminComponent } from './admin.component' import { AdminComponent } from './admin.component'
import { import {
@ -18,6 +19,7 @@ import {
EditBasicConfigurationComponent, EditBasicConfigurationComponent,
EditConfigurationService, EditConfigurationService,
EditCustomConfigComponent, EditCustomConfigComponent,
EditHomepageComponent,
EditInstanceInformationComponent, EditInstanceInformationComponent,
EditLiveConfigurationComponent, EditLiveConfigurationComponent,
EditVODTranscodingComponent EditVODTranscodingComponent
@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
SharedVideoCommentModule, SharedVideoCommentModule,
SharedActorImageModule, SharedActorImageModule,
SharedActorImageEditModule, SharedActorImageEditModule,
SharedCustomMarkupModule,
TableModule, TableModule,
SelectButtonModule, SelectButtonModule,
@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
EditVODTranscodingComponent, EditVODTranscodingComponent,
EditLiveConfigurationComponent, EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent, EditAdvancedConfigurationComponent,
EditInstanceInformationComponent EditInstanceInformationComponent,
EditHomepageComponent
], ],
exports: [ exports: [

View File

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

View File

@ -1,7 +1,9 @@
import { pairwise } from 'rxjs/operators' 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 { FormGroup } from '@angular/forms'
import { MenuService } from '@app/core'
import { ServerConfig } from '@shared/models' import { ServerConfig } from '@shared/models'
import { ConfigService } from '../shared/config.service' import { ConfigService } from '../shared/config.service'
@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service'
templateUrl: './edit-basic-configuration.component.html', templateUrl: './edit-basic-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ] styleUrls: [ './edit-custom-config.component.scss' ]
}) })
export class EditBasicConfigurationComponent implements OnInit { export class EditBasicConfigurationComponent implements OnInit, OnChanges {
@Input() form: FormGroup @Input() form: FormGroup
@Input() formErrors: any @Input() formErrors: any
@Input() serverConfig: ServerConfig @Input() serverConfig: ServerConfig
signupAlertMessage: string signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
constructor ( constructor (
private configService: ConfigService private configService: ConfigService,
private menuService: MenuService
) { } ) { }
ngOnInit () { ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField() this.checkSignupField()
} }
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.buildLandingPageOptions()
}
}
getVideoQuotaOptions () { getVideoQuotaOptions () {
return this.configService.videoQuotaOptions return this.configService.videoQuotaOptions
} }
@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit {
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true 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 () { private checkSignupField () {
const signupControl = this.form.get('signup.enabled') 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"> <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"> <ng-container ngbNavItem="instance-information">
<a ngbNavLink i18n>Instance information</a> <a ngbNavLink i18n>Information</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems"> <my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
@ -13,7 +21,7 @@
</ng-container> </ng-container>
<ng-container ngbNavItem="basic-configuration"> <ng-container ngbNavItem="basic-configuration">
<a ngbNavLink i18n>Basic configuration</a> <a ngbNavLink i18n>Basic</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig"> <my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
@ -40,7 +48,7 @@
</ng-container> </ng-container>
<ng-container ngbNavItem="advanced-configuration"> <ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced configuration</a> <a ngbNavLink i18n>Advanced</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors"> <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 { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, OnInit } from '@angular/core' import { Component, OnInit } from '@angular/core'
@ -24,9 +25,14 @@ import {
} from '@app/shared/form-validators/custom-config-validators' } 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 { 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 { 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' import { EditConfigurationService } from './edit-configuration.service'
type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage
}
@Component({ @Component({
selector: 'my-edit-custom-config', selector: 'my-edit-custom-config',
templateUrl: './edit-custom-config.component.html', templateUrl: './edit-custom-config.component.html',
@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service'
export class EditCustomConfigComponent extends FormReactive implements OnInit { export class EditCustomConfigComponent extends FormReactive implements OnInit {
activeNav: string activeNav: string
customConfig: CustomConfig customConfig: ComponentCustomConfig
serverConfig: ServerConfig serverConfig: ServerConfig
homepage: CustomPage
languageItems: SelectOptionsItem[] = [] languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = [] categoryItems: SelectOptionsItem[] = []
@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
protected formValidatorService: FormValidatorService, protected formValidatorService: FormValidatorService,
private notifier: Notifier, private notifier: Notifier,
private configService: ConfigService, private configService: ConfigService,
private customPage: CustomPageService,
private serverService: ServerService, private serverService: ServerService,
private editConfigurationService: EditConfigurationService private editConfigurationService: EditConfigurationService
) { ) {
@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig() this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig() this.serverService.getConfig()
.subscribe(config => { .subscribe(config => this.serverConfig = config)
this.serverConfig = config
})
const formGroupData: { [key in keyof CustomConfig ]: any } = { const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
instance: { instance: {
name: INSTANCE_NAME_VALIDATOR, name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR, shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
disableLocalSearch: null, disableLocalSearch: null,
isDefaultSearch: null isDefaultSearch: null
} }
},
instanceCustomHomepage: {
content: null
} }
} }
@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
} }
async formValidated () { 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( .subscribe(
res => { ([ resConfig ]) => {
this.customConfig = res const instanceCustomHomepage = {
content: value.instanceCustomHomepage.content
}
this.customConfig = { ...resConfig, instanceCustomHomepage }
// Reload general configuration // Reload general configuration
this.serverService.resetConfig() this.serverService.resetConfig()
.subscribe(config => this.serverConfig = config)
this.updateForm() this.updateForm()
@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
} }
private loadConfigAndUpdateForm () { private loadConfigAndUpdateForm () {
this.configService.getCustomConfig() forkJoin([
.subscribe(config => { this.configService.getCustomConfig(),
this.customConfig = config this.customPage.getInstanceHomepage()
])
.subscribe(([ config, homepage ]) => {
this.customConfig = { ...config, instanceCustomHomepage: homepage }
this.updateForm() this.updateForm()
// Force form validation // 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-basic-configuration.component'
export * from './edit-configuration.service' export * from './edit-configuration.service'
export * from './edit-custom-config.component' export * from './edit-custom-config.component'
export * from './edit-homepage.component'
export * from './edit-instance-information.component' export * from './edit-instance-information.component'
export * from './edit-live-configuration.component' export * from './edit-live-configuration.component'
export * from './edit-vod-transcoding.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 // Before HTML rendering restore line feed for markdown list compatibility
const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n') const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
const html = await this.markdownService.textMarkdownToHTML(commentText, true, true) 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 ]) this.newParentComments = this.parentComments.concat([ this.comment ])
if (this.comment.account) { if (this.comment.account) {

View File

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

View File

@ -13,6 +13,10 @@ const routes: Routes = [
canDeactivate: [ MenuGuards.open() ], canDeactivate: [ MenuGuards.open() ],
loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule) loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
}, },
{
path: 'home',
loadChildren: () => import('./+home/home.module').then(m => m.HomeModule)
},
{ {
path: 'my-account', path: 'my-account',
loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule) 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 = { this.broadcastMessage = {
message: await this.markdownService.completeMarkdownToHTML(messageConfig.message), message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true),
dismissable: messageConfig.dismissable, dismissable: messageConfig.dismissable,
class: classes[messageConfig.level] class: classes[messageConfig.level]
} }

View File

@ -1,8 +1,19 @@
import { fromEvent } from 'rxjs' import { fromEvent } from 'rxjs'
import { debounceTime } from 'rxjs/operators' import { debounceTime } from 'rxjs/operators'
import { Injectable } from '@angular/core' 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' import { ScreenService } from '../wrappers'
export type MenuLink = {
icon: GlobalIconName
label: string
menuLabel: string
path: string
priority: number
}
@Injectable() @Injectable()
export class MenuService { export class MenuService {
isMenuDisplayed = true isMenuDisplayed = true
@ -48,6 +59,53 @@ export class MenuService {
this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser 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 () { private handleWindowResize () {
// On touch screens, do not handle window resize event since opened menu is handled with a content overlay // On touch screens, do not handle window resize event since opened menu is handled with a content overlay
if (this.screenService.isInTouchScreen()) return if (this.screenService.isInTouchScreen()) return

View File

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

View File

@ -17,12 +17,15 @@ type MarkdownParsers = {
enhancedMarkdownIt: MarkdownIt enhancedMarkdownIt: MarkdownIt
enhancedWithHTMLMarkdownIt: MarkdownIt enhancedWithHTMLMarkdownIt: MarkdownIt
completeMarkdownIt: MarkdownIt unsafeMarkdownIt: MarkdownIt
customPageMarkdownIt: MarkdownIt
} }
type MarkdownConfig = { type MarkdownConfig = {
rules: string[] rules: string[]
html: boolean html: boolean
breaks: boolean
escape?: boolean escape?: boolean
} }
@ -35,18 +38,24 @@ export class MarkdownService {
private markdownParsers: MarkdownParsers = { private markdownParsers: MarkdownParsers = {
textMarkdownIt: null, textMarkdownIt: null,
textWithHTMLMarkdownIt: null, textWithHTMLMarkdownIt: null,
enhancedMarkdownIt: null, enhancedMarkdownIt: null,
enhancedWithHTMLMarkdownIt: null, enhancedWithHTMLMarkdownIt: null,
completeMarkdownIt: null
unsafeMarkdownIt: null,
customPageMarkdownIt: null
} }
private parsersConfig: MarkdownParserConfigs = { private parsersConfig: MarkdownParserConfigs = {
textMarkdownIt: { rules: TEXT_RULES, html: false }, textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false },
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true }, textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true },
enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false }, enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false },
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true }, 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 private emojiModule: any
@ -54,22 +63,26 @@ export class MarkdownService {
constructor (private htmlRenderer: HtmlRendererService) {} constructor (private htmlRenderer: HtmlRendererService) {}
textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) { 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) { 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) { unsafeMarkdownToHTML (markdown: string, _trustedInput: true) {
return this.render('completeMarkdownIt', markdown, 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) { 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 t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
const url = buildVideoLink({ startTime: t }) 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 '' if (!markdown) return ''
const config = this.parsersConfig[ name ] const config = this.parsersConfig[ name ]
@ -96,7 +115,7 @@ export class MarkdownService {
let html = this.markdownParsers[ name ].render(markdown) let html = this.markdownParsers[ name ].render(markdown)
html = this.avoidTruncatedTags(html) html = this.avoidTruncatedTags(html)
if (config.escape) return this.htmlRenderer.toSafeHtml(html) if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags)
return html return html
} }
@ -105,7 +124,7 @@ export class MarkdownService {
// FIXME: import('...') returns a struct module, containing a "default" field // FIXME: import('...') returns a struct module, containing a "default" field
const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default 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) { for (const rule of config.rules) {
markdownIt.enable(rule) markdownIt.enable(rule)

View File

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

View File

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

View File

@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators'
import { ViewportScroller } from '@angular/common' import { ViewportScroller } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core' import { Component, OnInit, ViewChild } from '@angular/core'
import { Router } from '@angular/router' 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 { scrollToTop } from '@app/helpers'
import { LanguageChooserComponent } from '@app/menu/language-chooser.component' import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
@ -35,6 +45,8 @@ export class MenuComponent implements OnInit {
currentInterfaceLanguage: string currentInterfaceLanguage: string
commonMenuLinks: MenuLink[] = []
private languages: VideoConstant<string>[] = [] private languages: VideoConstant<string>[] = []
private serverConfig: ServerConfig private serverConfig: ServerConfig
private routesPerRight: { [role in UserRight]?: string } = { private routesPerRight: { [role in UserRight]?: string } = {
@ -80,7 +92,10 @@ export class MenuComponent implements OnInit {
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig() this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig() this.serverService.getConfig()
.subscribe(config => this.serverConfig = config) .subscribe(config => {
this.serverConfig = config
this.buildMenuLinks()
})
this.isLoggedIn = this.authService.isLoggedIn() this.isLoggedIn = this.authService.isLoggedIn()
if (this.isLoggedIn === true) { if (this.isLoggedIn === true) {
@ -241,6 +256,10 @@ export class MenuComponent implements OnInit {
} }
} }
private buildMenuLinks () {
this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig)
}
private buildUserLanguages () { private buildUserLanguages () {
if (!this.user) { if (!this.user) {
this.videoLanguages = [] 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> <a ngbNavLink i18n>Complete preview</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div #previewElement></div>
<div [innerHTML]="previewHTML"></div> <div [innerHTML]="previewHTML"></div>
</ng-template> </ng-template>
</ng-container> </ng-container>

View File

@ -1,9 +1,10 @@
import { ViewportScroller } from '@angular/common'
import truncate from 'lodash-es/truncate' import truncate from 'lodash-es/truncate'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { debounceTime, distinctUntilChanged } from 'rxjs/operators' import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
import { ViewportScroller } from '@angular/common'
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { SafeHtml } from '@angular/platform-browser'
import { MarkdownService, ScreenService } from '@app/core' import { MarkdownService, ScreenService } from '@app/core'
@Component({ @Component({
@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core'
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
@Input() content = '' @Input() content = ''
@Input() classes: string[] | { [klass: string]: any[] | any } = [] @Input() classes: string[] | { [klass: string]: any[] | any } = []
@Input() textareaMaxWidth = '100%' @Input() textareaMaxWidth = '100%'
@Input() textareaHeight = '150px' @Input() textareaHeight = '150px'
@Input() truncate: number @Input() truncate: number
@Input() markdownType: 'text' | 'enhanced' = 'text' @Input() markdownType: 'text' | 'enhanced' = 'text'
@Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
@Input() markdownVideo = false @Input() markdownVideo = false
@Input() name = 'description' @Input() name = 'description'
@ViewChild('textarea') textareaElement: ElementRef @ViewChild('textarea') textareaElement: ElementRef
@ViewChild('previewElement') previewElement: ElementRef
truncatedPreviewHTML: SafeHtml | string = ''
previewHTML: SafeHtml | string = ''
truncatedPreviewHTML = ''
previewHTML = ''
isMaximized = false isMaximized = false
maximizeInText = $localize`Maximize editor` maximizeInText = $localize`Maximize editor`
@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
} }
private async markdownRender (text: string) { private async markdownRender (text: string) {
const html = this.markdownType === 'text' ? let html: string
await this.markdownService.textMarkdownToHTML(text) :
await this.markdownService.enhancedMarkdownToHTML(text)
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, 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.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 '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' } from './angular'
import { AUTH_INTERCEPTOR_PROVIDER } from './auth' import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
import { CustomPageService } from './custom-page'
import { DateToggleComponent } from './date' import { DateToggleComponent } from './date'
import { FeedComponent } from './feeds' import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders' import { LoaderComponent, SmallLoaderComponent } from './loaders'
@ -171,7 +172,9 @@ import { VideoChannelService } from './video-channel'
VideoCaptionService, VideoCaptionService,
VideoChannelService VideoChannelService,
CustomPageService
] ]
}) })
export class SharedMainModule { } 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: { function buildPlaylistLink (options: {
baseUrl?: string baseUrl?: string
playlistPosition: number playlistPosition?: number
}) { }) {
const { baseUrl } = options 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 { LiveManager } from './server/lib/live-manager'
import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes' import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes'
import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
import { ServerConfigManager } from '@server/lib/server-config-manager'
// ----------- Command line ----------- // ----------- Command line -----------
@ -262,7 +263,8 @@ async function startApplication () {
await Promise.all([ await Promise.all([
Emailer.Instance.checkConnection(), Emailer.Instance.checkConnection(),
JobQueue.Instance.init() JobQueue.Instance.init(),
ServerConfigManager.Instance.init()
]) ])
// Caches initializations // Caches initializations

View File

@ -1,8 +1,8 @@
import { ServerConfigManager } from '@server/lib/server-config-manager'
import * as express from 'express' import * as express from 'express'
import { remove, writeJSON } from 'fs-extra' import { remove, writeJSON } from 'fs-extra'
import { snakeCase } from 'lodash' import { snakeCase } from 'lodash'
import validator from 'validator' import validator from 'validator'
import { getServerConfig } from '@server/lib/config'
import { UserRight } from '../../../shared' import { UserRight } from '../../../shared'
import { About } from '../../../shared/models/server/about.model' import { About } from '../../../shared/models/server/about.model'
import { CustomConfig } from '../../../shared/models/server/custom-config.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) { 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) 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 { accountsRouter } from './accounts'
import { bulkRouter } from './bulk' import { bulkRouter } from './bulk'
import { configRouter } from './config' import { configRouter } from './config'
import { customPageRouter } from './custom-page'
import { jobsRouter } from './jobs' import { jobsRouter } from './jobs'
import { oauthClientsRouter } from './oauth-clients' import { oauthClientsRouter } from './oauth-clients'
import { overviewsRouter } from './overviews' import { overviewsRouter } from './overviews'
@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/search', searchRouter) apiRouter.use('/search', searchRouter)
apiRouter.use('/overviews', overviewsRouter) apiRouter.use('/overviews', overviewsRouter)
apiRouter.use('/plugins', pluginRouter) apiRouter.use('/plugins', pluginRouter)
apiRouter.use('/custom-pages', customPageRouter)
apiRouter.use('/ping', pong) apiRouter.use('/ping', pong)
apiRouter.use('/*', badRequest) apiRouter.use('/*', badRequest)

View File

@ -3,7 +3,7 @@ import { move, readFile } from 'fs-extra'
import * as magnetUtil from 'magnet-uri' import * as magnetUtil from 'magnet-uri'
import * as parseTorrent from 'parse-torrent' import * as parseTorrent from 'parse-torrent'
import { join } from 'path' 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 { setVideoTags } from '@server/lib/video'
import { FilteredModelAttributes } from '@server/types' import { FilteredModelAttributes } from '@server/types'
import { import {
@ -134,7 +134,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
const targetUrl = body.targetUrl const targetUrl = body.targetUrl
const user = res.locals.oauth.token.User 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 // Get video infos
let youtubeDLInfo: YoutubeDLInfo let youtubeDLInfo: YoutubeDLInfo

View File

@ -2,7 +2,7 @@ import * as cors from 'cors'
import * as express from 'express' import * as express from 'express'
import { join } from 'path' import { join } from 'path'
import { serveIndexHTML } from '@server/lib/client-html' 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 { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model' import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
import { root } from '../helpers/core-utils' import { root } from '../helpers/core-utils'
@ -203,10 +203,10 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
} }
}, },
plugin: { plugin: {
registered: getRegisteredPlugins() registered: ServerConfigManager.Instance.getRegisteredPlugins()
}, },
theme: { theme: {
registered: getRegisteredThemes(), registered: ServerConfigManager.Instance.getRegisteredThemes(),
default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
}, },
email: { email: {
@ -222,13 +222,13 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
webtorrent: { webtorrent: {
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
}, },
enabledResolutions: getEnabledResolutions('vod') enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
}, },
live: { live: {
enabled: CONFIG.LIVE.ENABLED, enabled: CONFIG.LIVE.ENABLED,
transcoding: { transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED, enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: getEnabledResolutions('live') enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
} }
}, },
import: { 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 sanitizeHtml = require('sanitize-html')
const markdownItEmoji = require('markdown-it-emoji/light') const markdownItEmoji = require('markdown-it-emoji/light')
@ -18,7 +20,7 @@ const toSafeHtml = text => {
const html = markdownIt.render(textWithLineFeed) const html = markdownIt.render(textWithLineFeed)
// Convert to safe Html // Convert to safe Html
return sanitizeHtml(html, SANITIZE_OPTIONS) return sanitizeHtml(html, sanitizeOptions)
} }
const mdToPlainText = text => { const mdToPlainText = text => {
@ -28,7 +30,7 @@ const mdToPlainText = text => {
const html = markdownIt.render(text) const html = markdownIt.render(text)
// Convert to safe Html // Convert to safe Html
const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS) const safeHtml = sanitizeHtml(html, sanitizeOptions)
return safeHtml.replace(/<[^>]+>/g, '') return safeHtml.replace(/<[^>]+>/g, '')
.replace(/\n$/, '') .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 { VideoTagModel } from '../models/video/video-tag'
import { VideoViewModel } from '../models/video/video-view' import { VideoViewModel } from '../models/video/video-view'
import { CONFIG } from './config' 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 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) {
ThumbnailModel, ThumbnailModel,
TrackerModel, TrackerModel,
VideoTrackerModel, VideoTrackerModel,
PluginModel PluginModel,
ActorCustomPageModel
]) ])
// Check extensions exist in the database // 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 { getActivityStreamDuration } from '../models/video/video-format-utils'
import { VideoPlaylistModel } from '../models/video/video-playlist' import { VideoPlaylistModel } from '../models/video/video-playlist'
import { MAccountActor, MChannelActor } from '../types/models' import { MAccountActor, MChannelActor } from '../types/models'
import { getHTMLServerConfig } from './config' import { ServerConfigManager } from './server-config-manager'
type Tags = { type Tags = {
ogType: string ogType: string
@ -211,7 +211,7 @@ class ClientHtml {
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
const buffer = await readFile(path) const buffer = await readFile(path)
const serverConfig = await getHTMLServerConfig() const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
let html = buffer.toString() let html = buffer.toString()
html = await ClientHtml.addAsyncPluginCSS(html) html = await ClientHtml.addAsyncPluginCSS(html)
@ -280,7 +280,7 @@ class ClientHtml {
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
const buffer = await readFile(path) const buffer = await readFile(path)
const serverConfig = await getHTMLServerConfig() const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
let html = buffer.toString() 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 { move, remove, stat } from 'fs-extra'
import { extname } from 'path' import { extname } from 'path'
import { retryTransactionWrapper } from '@server/helpers/database-utils' import { retryTransactionWrapper } from '@server/helpers/database-utils'
import { YoutubeDL } from '@server/helpers/youtube-dl'
import { isPostImportVideoAccepted } from '@server/lib/moderation' import { isPostImportVideoAccepted } from '@server/lib/moderation'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { ServerConfigManager } from '@server/lib/server-config-manager'
import { isAbleToUploadVideo } from '@server/lib/user' import { isAbleToUploadVideo } from '@server/lib/user'
import { addOptimizeOrMergeAudioJob } from '@server/lib/video' import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 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 { federateVideoIfNeeded } from '../../activitypub/videos'
import { Notifier } from '../../notifier' import { Notifier } from '../../notifier'
import { generateVideoMiniature } from '../../thumbnail' import { generateVideoMiniature } from '../../thumbnail'
import { YoutubeDL } from '@server/helpers/youtube-dl'
import { getEnabledResolutions } from '@server/lib/config'
async function processVideoImport (job: Bull.Job) { async function processVideoImport (job: Bull.Job) {
const payload = job.data as VideoImportPayload const payload = job.data as VideoImportPayload
@ -76,7 +76,7 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub
videoImportId: videoImport.id videoImportId: videoImport.id
} }
const youtubeDL = new YoutubeDL(videoImport.targetUrl, getEnabledResolutions('vod')) const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
return processFile( return processFile(
() => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT), () => 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 { PeerTubeHelpers } from '@server/types/plugins'
import { VideoBlacklistCreate } from '@shared/models' import { VideoBlacklistCreate } from '@shared/models'
import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist' import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
import { getServerConfig } from '../config' import { ServerConfigManager } from '../server-config-manager'
import { blacklistVideo, unblacklistVideo } from '../video-blacklist' import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
import { UserModel } from '@server/models/user/user' import { UserModel } from '@server/models/user/user'
@ -147,7 +147,7 @@ function buildConfigHelpers () {
}, },
getServerConfig () { 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 './blocklist'
import './bulk' import './bulk'
import './config' import './config'
import './custom-pages'
import './contact-form' import './contact-form'
import './debug' import './debug'
import './follows' 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 './follow-constraints'
import './follows' import './follows'
import './follows-moderation' import './follows-moderation'
import './homepage'
import './handle-down' import './handle-down'
import './jobs' import './jobs'
import './logs' 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 './account'
export * from './actor-custom-page'
export * from './account-blocklist' export * from './account-blocklist'

View File

@ -28,9 +28,24 @@ function isCatchable (value: any) {
return value && typeof value.catch === 'function' 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 { export {
randomInt, randomInt,
compareSemVer, compareSemVer,
isPromise, isPromise,
isCatchable isCatchable,
sortObjectComparator
} }

View File

@ -1,8 +1,10 @@
export const SANITIZE_OPTIONS = { export function getSanitizeOptions () {
return {
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
allowedSchemes: [ 'http', 'https' ], allowedSchemes: [ 'http', 'https' ],
allowedAttributes: { allowedAttributes: {
a: [ 'href', 'class', 'target', 'rel' ] 'a': [ 'href', 'class', 'target', 'rel' ],
'*': [ 'data-*' ]
}, },
transformTags: { transformTags: {
a: (tagName: string, attribs: any) => { a: (tagName: string, attribs: any) => {
@ -19,6 +21,24 @@ export const SANITIZE_OPTIONS = {
} }
} }
} }
}
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 // Thanks: https://stackoverflow.com/a/12034334
export function escapeHTML (stringParam: string) { export function escapeHTML (stringParam: string) {

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 './cli/cli'
export * from './custom-pages/custom-pages'
export * from './feeds/feeds' export * from './feeds/feeds'
export * from './mock-servers/mock-instances-index' 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.model'
export * from './actor-image.type' export * from './actor-image.type'
export * from './actor.model' export * from './actor.model'
export * from './custom-page.model'
export * from './follow.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 './activitypub'
export * from './actors' export * from './actors'
export * from './moderation' export * from './moderation'
export * from './custom-markup'
export * from './bulk' export * from './bulk'
export * from './redundancy' export * from './redundancy'
export * from './users' export * from './users'

View File

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

View File

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

View File

@ -247,6 +247,8 @@ tags:
Administrators can also enable the use of a remote search system, indexing Administrators can also enable the use of a remote search system, indexing
videos and channels not could be not federated by the instance. videos and channels not could be not federated by the instance.
- name: Homepage
description: Get and update the custom homepage
- name: Video Mirroring - name: Video Mirroring
description: | description: |
PeerTube instances can mirror videos from one another, and help distribute some videos. PeerTube instances can mirror videos from one another, and help distribute some videos.
@ -281,6 +283,9 @@ x-tagGroups:
- name: Search - name: Search
tags: tags:
- Search - Search
- name: Custom pages
tags:
- Homepage
- name: Moderation - name: Moderation
tags: tags:
- Abuses - Abuses
@ -477,6 +482,40 @@ paths:
'200': '200':
description: successful operation 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}: /jobs/{state}:
get: get:
summary: List instance jobs summary: List instance jobs
@ -5740,6 +5779,12 @@ components:
indexUrl: indexUrl:
type: string type: string
format: url format: url
homepage:
type: object
properties:
enabled:
type: boolean
ServerConfigAbout: ServerConfigAbout:
properties: properties:
instance: instance:
@ -5930,6 +5975,12 @@ components:
type: boolean type: boolean
manualApproval: manualApproval:
type: boolean type: boolean
CustomHomepage:
properties:
content:
type: string
Follow: Follow:
properties: properties:
id: id: