diff --git a/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts b/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts index c45269be4..dd774a4ef 100644 --- a/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts +++ b/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts @@ -14,6 +14,6 @@ export class AboutPeertubeContributorsComponent implements OnInit { constructor (private markdownService: MarkdownService) { } async ngOnInit () { - this.creditsHtml = await this.markdownService.completeMarkdownToHTML(this.markdown) + this.creditsHtml = await this.markdownService.unsafeMarkdownToHTML(this.markdown, true) } } diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index 45366f9ec..a7fe20b07 100644 --- a/client/src/app/+admin/admin.module.ts +++ b/client/src/app/+admin/admin.module.ts @@ -4,12 +4,13 @@ import { TableModule } from 'primeng/table' import { NgModule } from '@angular/core' import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit' +import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module' +import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup' import { SharedFormModule } from '@app/shared/shared-forms' import { SharedGlobalIconModule } from '@app/shared/shared-icons' import { SharedMainModule } from '@app/shared/shared-main' import { SharedModerationModule } from '@app/shared/shared-moderation' import { SharedVideoCommentModule } from '@app/shared/shared-video-comment' -import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' import { AdminRoutingModule } from './admin-routing.module' import { AdminComponent } from './admin.component' import { @@ -18,6 +19,7 @@ import { EditBasicConfigurationComponent, EditConfigurationService, EditCustomConfigComponent, + EditHomepageComponent, EditInstanceInformationComponent, EditLiveConfigurationComponent, EditVODTranscodingComponent @@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom SharedVideoCommentModule, SharedActorImageModule, SharedActorImageEditModule, + SharedCustomMarkupModule, TableModule, SelectButtonModule, @@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom EditVODTranscodingComponent, EditLiveConfigurationComponent, EditAdvancedConfigurationComponent, - EditInstanceInformationComponent + EditInstanceInformationComponent, + EditHomepageComponent ], exports: [ diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html index 84a793ae4..451e6a34a 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html @@ -26,22 +26,13 @@
-
- -
+
{{ formErrors.instance.defaultClientRoute }}
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts index 34d05f9f3..d50148e7a 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts @@ -1,7 +1,9 @@ import { pairwise } from 'rxjs/operators' -import { Component, Input, OnInit } from '@angular/core' +import { SelectOptionsItem } from 'src/types/select-options-item.model' +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' import { FormGroup } from '@angular/forms' +import { MenuService } from '@app/core' import { ServerConfig } from '@shared/models' import { ConfigService } from '../shared/config.service' @@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service' templateUrl: './edit-basic-configuration.component.html', styleUrls: [ './edit-custom-config.component.scss' ] }) -export class EditBasicConfigurationComponent implements OnInit { +export class EditBasicConfigurationComponent implements OnInit, OnChanges { @Input() form: FormGroup @Input() formErrors: any @Input() serverConfig: ServerConfig signupAlertMessage: string + defaultLandingPageOptions: SelectOptionsItem[] = [] constructor ( - private configService: ConfigService + private configService: ConfigService, + private menuService: MenuService ) { } ngOnInit () { + this.buildLandingPageOptions() this.checkSignupField() } + ngOnChanges (changes: SimpleChanges) { + if (changes['serverConfig']) { + this.buildLandingPageOptions() + } + } + getVideoQuotaOptions () { return this.configService.videoQuotaOptions } @@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit { return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true } + buildLandingPageOptions () { + this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig) + .map(o => ({ + id: o.path, + label: o.label, + description: o.path + })) + } + private checkSignupField () { const signupControl = this.form.get('signup.enabled') diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index b6365614d..3ceea02ca 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html @@ -3,8 +3,16 @@ diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts index 8fa1de326..2f7e0cf07 100644 --- a/client/src/app/menu/menu.component.ts +++ b/client/src/app/menu/menu.component.ts @@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators' import { ViewportScroller } from '@angular/common' import { Component, OnInit, ViewChild } from '@angular/core' import { Router } from '@angular/router' -import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core' +import { + AuthService, + AuthStatus, + AuthUser, + MenuLink, + MenuService, + RedirectService, + ScreenService, + ServerService, + UserService +} from '@app/core' import { scrollToTop } from '@app/helpers' import { LanguageChooserComponent } from '@app/menu/language-chooser.component' import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' @@ -35,6 +45,8 @@ export class MenuComponent implements OnInit { currentInterfaceLanguage: string + commonMenuLinks: MenuLink[] = [] + private languages: VideoConstant[] = [] private serverConfig: ServerConfig private routesPerRight: { [role in UserRight]?: string } = { @@ -80,7 +92,10 @@ export class MenuComponent implements OnInit { ngOnInit () { this.serverConfig = this.serverService.getTmpConfig() this.serverService.getConfig() - .subscribe(config => this.serverConfig = config) + .subscribe(config => { + this.serverConfig = config + this.buildMenuLinks() + }) this.isLoggedIn = this.authService.isLoggedIn() if (this.isLoggedIn === true) { @@ -241,6 +256,10 @@ export class MenuComponent implements OnInit { } } + private buildMenuLinks () { + this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig) + } + private buildUserLanguages () { if (!this.user) { this.videoLanguages = [] diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html new file mode 100644 index 000000000..da81006b9 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html @@ -0,0 +1,8 @@ +
+ + +
{{ channel.displayName }}
+
{{ channel.name }}
+ +
{{ channel.description }}
+
diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss new file mode 100644 index 000000000..85018afe2 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss @@ -0,0 +1,9 @@ +@import '_variables'; +@import '_mixins'; + +.channel { + border-radius: 15px; + padding: 10px; + width: min-content; + border: 1px solid pvar(--mainColor); +} diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts new file mode 100644 index 000000000..97bb5567e --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts @@ -0,0 +1,26 @@ +import { Component, Input, OnInit } from '@angular/core' +import { VideoChannel, VideoChannelService } from '../shared-main' + +/* + * Markup component that creates a channel miniature only +*/ + +@Component({ + selector: 'my-channel-miniature-markup', + templateUrl: 'channel-miniature-markup.component.html', + styleUrls: [ 'channel-miniature-markup.component.scss' ] +}) +export class ChannelMiniatureMarkupComponent implements OnInit { + @Input() name: string + + channel: VideoChannel + + constructor ( + private channelService: VideoChannelService + ) { } + + ngOnInit () { + this.channelService.getVideoChannel(this.name) + .subscribe(channel => this.channel = channel) + } +} diff --git a/client/src/app/shared/shared-custom-markup/custom-markup.service.ts b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts new file mode 100644 index 000000000..ffaf15710 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts @@ -0,0 +1,136 @@ +import { ComponentRef, Injectable } from '@angular/core' +import { MarkdownService } from '@app/core' +import { + ChannelMiniatureMarkupData, + EmbedMarkupData, + PlaylistMiniatureMarkupData, + VideoMiniatureMarkupData, + VideosListMarkupData +} from '@shared/models' +import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component' +import { DynamicElementService } from './dynamic-element.service' +import { EmbedMarkupComponent } from './embed-markup.component' +import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component' +import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component' +import { VideosListMarkupComponent } from './videos-list-markup.component' + +type BuilderFunction = (el: HTMLElement) => ComponentRef + +@Injectable() +export class CustomMarkupService { + private builders: { [ selector: string ]: BuilderFunction } = { + 'peertube-video-embed': el => this.embedBuilder(el, 'video'), + 'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'), + 'peertube-video-miniature': el => this.videoMiniatureBuilder(el), + 'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el), + 'peertube-channel-miniature': el => this.channelMiniatureBuilder(el), + 'peertube-videos-list': el => this.videosListBuilder(el) + } + + constructor ( + private dynamicElementService: DynamicElementService, + private markdown: MarkdownService + ) { } + + async buildElement (text: string) { + const html = await this.markdown.customPageMarkdownToHTML(text, this.getSupportedTags()) + + const rootElement = document.createElement('div') + rootElement.innerHTML = html + + for (const selector of this.getSupportedTags()) { + rootElement.querySelectorAll(selector) + .forEach((e: HTMLElement) => { + try { + const component = this.execBuilder(selector, e) + + this.dynamicElementService.injectElement(e, component) + } catch (err) { + console.error('Cannot inject component %s.', selector, err) + } + }) + } + + return rootElement + } + + private getSupportedTags () { + return Object.keys(this.builders) + } + + private execBuilder (selector: string, el: HTMLElement) { + return this.builders[selector](el) + } + + private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') { + const data = el.dataset as EmbedMarkupData + const component = this.dynamicElementService.createElement(EmbedMarkupComponent) + + this.dynamicElementService.setModel(component, { uuid: data.uuid, type }) + + return component + } + + private videoMiniatureBuilder (el: HTMLElement) { + const data = el.dataset as VideoMiniatureMarkupData + const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent) + + this.dynamicElementService.setModel(component, { uuid: data.uuid }) + + return component + } + + private playlistMiniatureBuilder (el: HTMLElement) { + const data = el.dataset as PlaylistMiniatureMarkupData + const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent) + + this.dynamicElementService.setModel(component, { uuid: data.uuid }) + + return component + } + + private channelMiniatureBuilder (el: HTMLElement) { + const data = el.dataset as ChannelMiniatureMarkupData + const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent) + + this.dynamicElementService.setModel(component, { name: data.name }) + + return component + } + + private videosListBuilder (el: HTMLElement) { + const data = el.dataset as VideosListMarkupData + const component = this.dynamicElementService.createElement(VideosListMarkupComponent) + + const model = { + title: data.title, + description: data.description, + sort: data.sort, + categoryOneOf: this.buildArrayNumber(data.categoryOneOf), + languageOneOf: this.buildArrayString(data.languageOneOf), + count: this.buildNumber(data.count) || 10 + } + + this.dynamicElementService.setModel(component, model) + + return component + } + + private buildNumber (value: string) { + if (!value) return undefined + + return parseInt(value, 10) + } + + private buildArrayNumber (value: string) { + if (!value) return undefined + + return value.split(',').map(v => parseInt(v, 10)) + } + + private buildArrayString (value: string) { + if (!value) return undefined + + return value.split(',') + } +} diff --git a/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts b/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts new file mode 100644 index 000000000..e967e30ac --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts @@ -0,0 +1,57 @@ +import { + ApplicationRef, + ComponentFactoryResolver, + ComponentRef, + EmbeddedViewRef, + Injectable, + Injector, + OnChanges, + SimpleChange, + SimpleChanges, + Type +} from '@angular/core' + +@Injectable() +export class DynamicElementService { + + constructor ( + private injector: Injector, + private applicationRef: ApplicationRef, + private componentFactoryResolver: ComponentFactoryResolver + ) { } + + createElement (ofComponent: Type) { + const div = document.createElement('div') + + const component = this.componentFactoryResolver.resolveComponentFactory(ofComponent) + .create(this.injector, [], div) + + return component + } + + injectElement (wrapper: HTMLElement, componentRef: ComponentRef) { + const hostView = componentRef.hostView as EmbeddedViewRef + + this.applicationRef.attachView(hostView) + wrapper.appendChild(hostView.rootNodes[0]) + } + + setModel (componentRef: ComponentRef, attributes: Partial) { + const changes: SimpleChanges = {} + + for (const key of Object.keys(attributes)) { + const previousValue = componentRef.instance[key] + const newValue = attributes[key] + + componentRef.instance[key] = newValue + changes[key] = new SimpleChange(previousValue, newValue, previousValue === undefined) + } + + const component = componentRef.instance + if (typeof (component as unknown as OnChanges).ngOnChanges === 'function') { + (component as unknown as OnChanges).ngOnChanges(changes) + } + + componentRef.changeDetectorRef.detectChanges() + } +} diff --git a/client/src/app/shared/shared-custom-markup/embed-markup.component.ts b/client/src/app/shared/shared-custom-markup/embed-markup.component.ts new file mode 100644 index 000000000..a854d89f6 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/embed-markup.component.ts @@ -0,0 +1,22 @@ +import { buildPlaylistLink, buildVideoLink, buildVideoOrPlaylistEmbed } from 'src/assets/player/utils' +import { environment } from 'src/environments/environment' +import { Component, ElementRef, Input, OnInit } from '@angular/core' + +@Component({ + selector: 'my-embed-markup', + template: '' +}) +export class EmbedMarkupComponent implements OnInit { + @Input() uuid: string + @Input() type: 'video' | 'playlist' = 'video' + + constructor (private el: ElementRef) { } + + ngOnInit () { + const link = this.type === 'video' + ? buildVideoLink({ baseUrl: `${environment.originServerUrl}/videos/embed/${this.uuid}` }) + : buildPlaylistLink({ baseUrl: `${environment.originServerUrl}/video-playlists/embed/${this.uuid}` }) + + this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed(link, this.uuid) + } +} diff --git a/client/src/app/shared/shared-custom-markup/index.ts b/client/src/app/shared/shared-custom-markup/index.ts new file mode 100644 index 000000000..14bde3ea9 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/index.ts @@ -0,0 +1,3 @@ +export * from './custom-markup.service' +export * from './dynamic-element.service' +export * from './shared-custom-markup.module' diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html new file mode 100644 index 000000000..4e1d1a13f --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html @@ -0,0 +1,2 @@ + + diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss new file mode 100644 index 000000000..281cef726 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss @@ -0,0 +1,7 @@ +@import '_variables'; +@import '_mixins'; + +my-video-playlist-miniature { + display: inline-block; + width: $video-thumbnail-width; +} diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts new file mode 100644 index 000000000..7aee450f1 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts @@ -0,0 +1,38 @@ +import { Component, Input, OnInit } from '@angular/core' +import { MiniatureDisplayOptions } from '../shared-video-miniature' +import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist' + +/* + * Markup component that creates a playlist miniature only +*/ + +@Component({ + selector: 'my-playlist-miniature-markup', + templateUrl: 'playlist-miniature-markup.component.html', + styleUrls: [ 'playlist-miniature-markup.component.scss' ] +}) +export class PlaylistMiniatureMarkupComponent implements OnInit { + @Input() uuid: string + + playlist: VideoPlaylist + + displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: false, + privacyLabel: false, + privacyText: false, + state: false, + blacklistInfo: false + } + + constructor ( + private playlistService: VideoPlaylistService + ) { } + + ngOnInit () { + this.playlistService.getVideoPlaylist(this.uuid) + .subscribe(playlist => this.playlist = playlist) + } +} diff --git a/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts new file mode 100644 index 000000000..4bbb71588 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts @@ -0,0 +1,49 @@ + +import { CommonModule } from '@angular/common' +import { NgModule } from '@angular/core' +import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' +import { SharedGlobalIconModule } from '../shared-icons' +import { SharedMainModule } from '../shared-main' +import { SharedVideoMiniatureModule } from '../shared-video-miniature' +import { SharedVideoPlaylistModule } from '../shared-video-playlist' +import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component' +import { CustomMarkupService } from './custom-markup.service' +import { DynamicElementService } from './dynamic-element.service' +import { EmbedMarkupComponent } from './embed-markup.component' +import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component' +import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component' +import { VideosListMarkupComponent } from './videos-list-markup.component' + +@NgModule({ + imports: [ + CommonModule, + + SharedMainModule, + SharedGlobalIconModule, + SharedVideoMiniatureModule, + SharedVideoPlaylistModule, + SharedActorImageModule + ], + + declarations: [ + VideoMiniatureMarkupComponent, + PlaylistMiniatureMarkupComponent, + ChannelMiniatureMarkupComponent, + EmbedMarkupComponent, + VideosListMarkupComponent + ], + + exports: [ + VideoMiniatureMarkupComponent, + PlaylistMiniatureMarkupComponent, + ChannelMiniatureMarkupComponent, + VideosListMarkupComponent, + EmbedMarkupComponent + ], + + providers: [ + CustomMarkupService, + DynamicElementService + ] +}) +export class SharedCustomMarkupModule { } diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html new file mode 100644 index 000000000..9b4930b6d --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html @@ -0,0 +1,6 @@ + + diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss new file mode 100644 index 000000000..81e265f29 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss @@ -0,0 +1,7 @@ +@import '_variables'; +@import '_mixins'; + +my-video-miniature { + display: inline-block; + width: $video-thumbnail-width; +} diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts new file mode 100644 index 000000000..79add0c3b --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts @@ -0,0 +1,44 @@ +import { Component, Input, OnInit } from '@angular/core' +import { AuthService } from '@app/core' +import { Video, VideoService } from '../shared-main' +import { MiniatureDisplayOptions } from '../shared-video-miniature' + +/* + * Markup component that creates a video miniature only +*/ + +@Component({ + selector: 'my-video-miniature-markup', + templateUrl: 'video-miniature-markup.component.html', + styleUrls: [ 'video-miniature-markup.component.scss' ] +}) +export class VideoMiniatureMarkupComponent implements OnInit { + @Input() uuid: string + + video: Video + + displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: false, + privacyLabel: false, + privacyText: false, + state: false, + blacklistInfo: false + } + + constructor ( + private auth: AuthService, + private videoService: VideoService + ) { } + + getUser () { + return this.auth.getUser() + } + + ngOnInit () { + this.videoService.getVideo({ videoId: this.uuid }) + .subscribe(video => this.video = video) + } +} diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html new file mode 100644 index 000000000..501f35e04 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html @@ -0,0 +1,13 @@ +
+

{{ title }}

+
{{ description }}
+ +
+ + +
+
diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss new file mode 100644 index 000000000..dcd931090 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss @@ -0,0 +1,9 @@ +@import '_variables'; +@import '_mixins'; + +my-video-miniature { + margin-right: 15px; + display: inline-block; + min-width: $video-thumbnail-width; + max-width: $video-thumbnail-width; +} diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts new file mode 100644 index 000000000..cc25d0a51 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts @@ -0,0 +1,60 @@ +import { Component, Input, OnInit } from '@angular/core' +import { AuthService } from '@app/core' +import { VideoSortField } from '@shared/models' +import { Video, VideoService } from '../shared-main' +import { MiniatureDisplayOptions } from '../shared-video-miniature' + +/* + * Markup component list videos depending on criterias +*/ + +@Component({ + selector: 'my-videos-list-markup', + templateUrl: 'videos-list-markup.component.html', + styleUrls: [ 'videos-list-markup.component.scss' ] +}) +export class VideosListMarkupComponent implements OnInit { + @Input() title: string + @Input() description: string + @Input() sort = '-publishedAt' + @Input() categoryOneOf: number[] + @Input() languageOneOf: string[] + @Input() count = 10 + + videos: Video[] + + displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: false, + privacyLabel: false, + privacyText: false, + state: false, + blacklistInfo: false + } + + constructor ( + private auth: AuthService, + private videoService: VideoService + ) { } + + getUser () { + return this.auth.getUser() + } + + ngOnInit () { + const options = { + videoPagination: { + currentPage: 1, + itemsPerPage: this.count + }, + categoryOneOf: this.categoryOneOf, + languageOneOf: this.languageOneOf, + sort: this.sort as VideoSortField + } + + this.videoService.getVideos(options) + .subscribe(({ data }) => this.videos = data) + } +} diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html index 513b543cd..6e70e2f37 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.html +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html @@ -19,6 +19,7 @@ Complete preview +
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts index 9b3ab9cf3..a233a4205 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts @@ -1,9 +1,10 @@ -import { ViewportScroller } from '@angular/common' import truncate from 'lodash-es/truncate' import { Subject } from 'rxjs' import { debounceTime, distinctUntilChanged } from 'rxjs/operators' +import { ViewportScroller } from '@angular/common' import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { SafeHtml } from '@angular/platform-browser' import { MarkdownService, ScreenService } from '@app/core' @Component({ @@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core' export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { @Input() content = '' + @Input() classes: string[] | { [klass: string]: any[] | any } = [] + @Input() textareaMaxWidth = '100%' @Input() textareaHeight = '150px' + @Input() truncate: number + @Input() markdownType: 'text' | 'enhanced' = 'text' + @Input() customMarkdownRenderer?: (text: string) => Promise + @Input() markdownVideo = false + @Input() name = 'description' @ViewChild('textarea') textareaElement: ElementRef + @ViewChild('previewElement') previewElement: ElementRef + + truncatedPreviewHTML: SafeHtml | string = '' + previewHTML: SafeHtml | string = '' - truncatedPreviewHTML = '' - previewHTML = '' isMaximized = false maximizeInText = $localize`Maximize editor` @@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { } private async markdownRender (text: string) { - const html = this.markdownType === 'text' ? - await this.markdownService.textMarkdownToHTML(text) : - await this.markdownService.enhancedMarkdownToHTML(text) + let html: string - return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html + if (this.customMarkdownRenderer) { + const result = await this.customMarkdownRenderer(text) + + if (result instanceof HTMLElement) { + html = '' + + const wrapperElement = this.previewElement.nativeElement as HTMLElement + wrapperElement.innerHTML = '' + wrapperElement.appendChild(result) + return + } + + html = result + } else if (this.markdownType === 'text') { + html = await this.markdownService.textMarkdownToHTML(text) + } else { + html = await this.markdownService.enhancedMarkdownToHTML(text) + } + + if (this.markdownVideo) { + html = this.markdownService.processVideoTimestamps(html) + } + + return html } } diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index 3af517927..a4dd72db6 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts @@ -72,6 +72,7 @@ const icons = { 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, + 'octagon': require('!!raw-loader?!../../../assets/images/feather/octagon.svg').default, 'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default } diff --git a/client/src/app/shared/shared-main/custom-page/custom-page.service.ts b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts new file mode 100644 index 000000000..e5c2b3cd4 --- /dev/null +++ b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts @@ -0,0 +1,38 @@ +import { of } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { CustomPage } from '@shared/models' +import { environment } from '../../../../environments/environment' + +@Injectable() +export class CustomPageService { + static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) { } + + getInstanceHomepage () { + return this.authHttp.get(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL) + .pipe( + catchError(err => { + if (err.status === 404) { + return of({ content: '' }) + } + + this.restExtractor.handleError(err) + }) + ) + } + + updateInstanceHomepage (content: string) { + return this.authHttp.put(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL, { content }) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-main/custom-page/index.ts b/client/src/app/shared/shared-main/custom-page/index.ts new file mode 100644 index 000000000..7269ece95 --- /dev/null +++ b/client/src/app/shared/shared-main/custom-page/index.ts @@ -0,0 +1 @@ +export * from './custom-page.service' diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 772198cb2..f9b6085cf 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -29,6 +29,7 @@ import { } from './angular' import { AUTH_INTERCEPTOR_PROVIDER } from './auth' import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' +import { CustomPageService } from './custom-page' import { DateToggleComponent } from './date' import { FeedComponent } from './feeds' import { LoaderComponent, SmallLoaderComponent } from './loaders' @@ -171,7 +172,9 @@ import { VideoChannelService } from './video-channel' VideoCaptionService, - VideoChannelService + VideoChannelService, + + CustomPageService ] }) export class SharedMainModule { } diff --git a/client/src/assets/images/feather/octagon.svg b/client/src/assets/images/feather/octagon.svg new file mode 100644 index 000000000..1ed9bacbf --- /dev/null +++ b/client/src/assets/images/feather/octagon.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index d7451fa1d..1243526d2 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts @@ -95,7 +95,7 @@ function buildVideoLink (options: { function buildPlaylistLink (options: { baseUrl?: string - playlistPosition: number + playlistPosition?: number }) { const { baseUrl } = options diff --git a/server.ts b/server.ts index 97dffe756..7aaf1e553 100644 --- a/server.ts +++ b/server.ts @@ -127,6 +127,7 @@ import { PluginManager } from './server/lib/plugins/plugin-manager' import { LiveManager } from './server/lib/live-manager' import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes' import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' +import { ServerConfigManager } from '@server/lib/server-config-manager' // ----------- Command line ----------- @@ -262,7 +263,8 @@ async function startApplication () { await Promise.all([ Emailer.Instance.checkConnection(), - JobQueue.Instance.init() + JobQueue.Instance.init(), + ServerConfigManager.Instance.init() ]) // Caches initializations diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 5ce7adc35..c9b5c8047 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -1,8 +1,8 @@ +import { ServerConfigManager } from '@server/lib/server-config-manager' import * as express from 'express' import { remove, writeJSON } from 'fs-extra' import { snakeCase } from 'lodash' import validator from 'validator' -import { getServerConfig } from '@server/lib/config' import { UserRight } from '../../../shared' import { About } from '../../../shared/models/server/about.model' import { CustomConfig } from '../../../shared/models/server/custom-config.model' @@ -43,7 +43,7 @@ configRouter.delete('/custom', ) async function getConfig (req: express.Request, res: express.Response) { - const json = await getServerConfig(req.ip) + const json = await ServerConfigManager.Instance.getServerConfig(req.ip) return res.json(json) } diff --git a/server/controllers/api/custom-page.ts b/server/controllers/api/custom-page.ts new file mode 100644 index 000000000..3c47f7b9a --- /dev/null +++ b/server/controllers/api/custom-page.ts @@ -0,0 +1,42 @@ +import * as express from 'express' +import { ServerConfigManager } from '@server/lib/server-config-manager' +import { ActorCustomPageModel } from '@server/models/account/actor-custom-page' +import { HttpStatusCode } from '@shared/core-utils' +import { UserRight } from '@shared/models' +import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares' + +const customPageRouter = express.Router() + +customPageRouter.get('/homepage/instance', + asyncMiddleware(getInstanceHomepage) +) + +customPageRouter.put('/homepage/instance', + authenticate, + ensureUserHasRight(UserRight.MANAGE_INSTANCE_CUSTOM_PAGE), + asyncMiddleware(updateInstanceHomepage) +) + +// --------------------------------------------------------------------------- + +export { + customPageRouter +} + +// --------------------------------------------------------------------------- + +async function getInstanceHomepage (req: express.Request, res: express.Response) { + const page = await ActorCustomPageModel.loadInstanceHomepage() + if (!page) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) + + return res.json(page.toFormattedJSON()) +} + +async function updateInstanceHomepage (req: express.Request, res: express.Response) { + const content = req.body.content + + await ActorCustomPageModel.updateInstanceHomepage(content) + ServerConfigManager.Instance.updateHomepageState(content) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index 7ade1df3a..28378654a 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts @@ -8,6 +8,7 @@ import { abuseRouter } from './abuse' import { accountsRouter } from './accounts' import { bulkRouter } from './bulk' import { configRouter } from './config' +import { customPageRouter } from './custom-page' import { jobsRouter } from './jobs' import { oauthClientsRouter } from './oauth-clients' import { overviewsRouter } from './overviews' @@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter) apiRouter.use('/search', searchRouter) apiRouter.use('/overviews', overviewsRouter) apiRouter.use('/plugins', pluginRouter) +apiRouter.use('/custom-pages', customPageRouter) apiRouter.use('/ping', pong) apiRouter.use('/*', badRequest) diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts index ee63c7b77..0d5d7a962 100644 --- a/server/controllers/api/videos/import.ts +++ b/server/controllers/api/videos/import.ts @@ -3,7 +3,7 @@ import { move, readFile } from 'fs-extra' import * as magnetUtil from 'magnet-uri' import * as parseTorrent from 'parse-torrent' import { join } from 'path' -import { getEnabledResolutions } from '@server/lib/config' +import { ServerConfigManager } from '@server/lib/server-config-manager' import { setVideoTags } from '@server/lib/video' import { FilteredModelAttributes } from '@server/types' import { @@ -134,7 +134,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response) const targetUrl = body.targetUrl const user = res.locals.oauth.token.User - const youtubeDL = new YoutubeDL(targetUrl, getEnabledResolutions('vod')) + const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod')) // Get video infos let youtubeDLInfo: YoutubeDLInfo diff --git a/server/controllers/static.ts b/server/controllers/static.ts index 8a747ec52..3870ebfe9 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts @@ -2,7 +2,7 @@ import * as cors from 'cors' import * as express from 'express' import { join } from 'path' import { serveIndexHTML } from '@server/lib/client-html' -import { getEnabledResolutions, getRegisteredPlugins, getRegisteredThemes } from '@server/lib/config' +import { ServerConfigManager } from '@server/lib/server-config-manager' import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model' import { root } from '../helpers/core-utils' @@ -203,10 +203,10 @@ async function generateNodeinfo (req: express.Request, res: express.Response) { } }, plugin: { - registered: getRegisteredPlugins() + registered: ServerConfigManager.Instance.getRegisteredPlugins() }, theme: { - registered: getRegisteredThemes(), + registered: ServerConfigManager.Instance.getRegisteredThemes(), default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) }, email: { @@ -222,13 +222,13 @@ async function generateNodeinfo (req: express.Request, res: express.Response) { webtorrent: { enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED }, - enabledResolutions: getEnabledResolutions('vod') + enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod') }, live: { enabled: CONFIG.LIVE.ENABLED, transcoding: { enabled: CONFIG.LIVE.TRANSCODING.ENABLED, - enabledResolutions: getEnabledResolutions('live') + enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live') } }, import: { diff --git a/server/helpers/markdown.ts b/server/helpers/markdown.ts index 2126bb752..41e57d857 100644 --- a/server/helpers/markdown.ts +++ b/server/helpers/markdown.ts @@ -1,4 +1,6 @@ -import { SANITIZE_OPTIONS, TEXT_WITH_HTML_RULES } from '@shared/core-utils' +import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils' + +const sanitizeOptions = getSanitizeOptions() const sanitizeHtml = require('sanitize-html') const markdownItEmoji = require('markdown-it-emoji/light') @@ -18,7 +20,7 @@ const toSafeHtml = text => { const html = markdownIt.render(textWithLineFeed) // Convert to safe Html - return sanitizeHtml(html, SANITIZE_OPTIONS) + return sanitizeHtml(html, sanitizeOptions) } const mdToPlainText = text => { @@ -28,7 +30,7 @@ const mdToPlainText = text => { const html = markdownIt.render(text) // Convert to safe Html - const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS) + const safeHtml = sanitizeHtml(html, sanitizeOptions) return safeHtml.replace(/<[^>]+>/g, '') .replace(/\n$/, '') diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 4cf7dcf0a..919f9ea6e 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 645 +const LAST_MIGRATION_VERSION = 650 // --------------------------------------------------------------------------- diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 75a13ec8b..38e7a76d0 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -44,6 +44,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla import { VideoTagModel } from '../models/video/video-tag' import { VideoViewModel } from '../models/video/video-view' import { CONFIG } from './config' +import { ActorCustomPageModel } from '@server/models/account/actor-custom-page' require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) { ThumbnailModel, TrackerModel, VideoTrackerModel, - PluginModel + PluginModel, + ActorCustomPageModel ]) // Check extensions exist in the database diff --git a/server/initializers/migrations/0650-actor-custom-pages.ts b/server/initializers/migrations/0650-actor-custom-pages.ts new file mode 100644 index 000000000..1338327e8 --- /dev/null +++ b/server/initializers/migrations/0650-actor-custom-pages.ts @@ -0,0 +1,33 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + { + const query = ` + CREATE TABLE IF NOT EXISTS "actorCustomPage" ( + "id" serial, + "content" TEXT, + "type" varchar(255) NOT NULL, + "actorId" integer NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp WITH time zone NOT NULL, + "updatedAt" timestamp WITH time zone NOT NULL, + PRIMARY KEY ("id") + ); + ` + + await utils.sequelize.query(query) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 85fdc8754..4b2968e8b 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -26,7 +26,7 @@ import { VideoChannelModel } from '../models/video/video-channel' import { getActivityStreamDuration } from '../models/video/video-format-utils' import { VideoPlaylistModel } from '../models/video/video-playlist' import { MAccountActor, MChannelActor } from '../types/models' -import { getHTMLServerConfig } from './config' +import { ServerConfigManager } from './server-config-manager' type Tags = { ogType: string @@ -211,7 +211,7 @@ class ClientHtml { if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] const buffer = await readFile(path) - const serverConfig = await getHTMLServerConfig() + const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() let html = buffer.toString() html = await ClientHtml.addAsyncPluginCSS(html) @@ -280,7 +280,7 @@ class ClientHtml { if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] const buffer = await readFile(path) - const serverConfig = await getHTMLServerConfig() + const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() let html = buffer.toString() diff --git a/server/lib/config.ts b/server/lib/config.ts deleted file mode 100644 index 18d49f05a..000000000 --- a/server/lib/config.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup' -import { getServerCommit } from '@server/helpers/utils' -import { CONFIG, isEmailEnabled } from '@server/initializers/config' -import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants' -import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models' -import { Hooks } from './plugins/hooks' -import { PluginManager } from './plugins/plugin-manager' -import { getThemeOrDefault } from './plugins/theme-utils' -import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles' - -async function getServerConfig (ip?: string): Promise { - const { allowed } = await Hooks.wrapPromiseFun( - isSignupAllowed, - { - ip - }, - 'filter:api.user.signup.allowed.result' - ) - - const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) - - const signup = { - allowed, - allowedForCurrentIP, - requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION - } - - const htmlConfig = await getHTMLServerConfig() - - return { ...htmlConfig, signup } -} - -// Config injected in HTML -let serverCommit: string -async function getHTMLServerConfig (): Promise { - if (serverCommit === undefined) serverCommit = await getServerCommit() - - const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) - - return { - instance: { - name: CONFIG.INSTANCE.NAME, - shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION, - isNSFW: CONFIG.INSTANCE.IS_NSFW, - defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, - defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE, - customizations: { - javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT, - css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS - } - }, - search: { - remoteUri: { - users: CONFIG.SEARCH.REMOTE_URI.USERS, - anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS - }, - searchIndex: { - enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED, - url: CONFIG.SEARCH.SEARCH_INDEX.URL, - disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH, - isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH - } - }, - plugin: { - registered: getRegisteredPlugins(), - registeredExternalAuths: getExternalAuthsPlugins(), - registeredIdAndPassAuths: getIdAndPassAuthPlugins() - }, - theme: { - registered: getRegisteredThemes(), - default: defaultTheme - }, - email: { - enabled: isEmailEnabled() - }, - contactForm: { - enabled: CONFIG.CONTACT_FORM.ENABLED - }, - serverVersion: PEERTUBE_VERSION, - serverCommit, - transcoding: { - hls: { - enabled: CONFIG.TRANSCODING.HLS.ENABLED - }, - webtorrent: { - enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED - }, - enabledResolutions: getEnabledResolutions('vod'), - profile: CONFIG.TRANSCODING.PROFILE, - availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod') - }, - live: { - enabled: CONFIG.LIVE.ENABLED, - - allowReplay: CONFIG.LIVE.ALLOW_REPLAY, - maxDuration: CONFIG.LIVE.MAX_DURATION, - maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, - maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, - - transcoding: { - enabled: CONFIG.LIVE.TRANSCODING.ENABLED, - enabledResolutions: getEnabledResolutions('live'), - profile: CONFIG.LIVE.TRANSCODING.PROFILE, - availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live') - }, - - rtmp: { - port: CONFIG.LIVE.RTMP.PORT - } - }, - import: { - videos: { - http: { - enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED - }, - torrent: { - enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED - } - } - }, - autoBlacklist: { - videos: { - ofUsers: { - enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED - } - } - }, - avatar: { - file: { - size: { - max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max - }, - extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME - } - }, - banner: { - file: { - size: { - max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max - }, - extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME - } - }, - video: { - image: { - extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME, - size: { - max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max - } - }, - file: { - extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME - } - }, - videoCaption: { - file: { - size: { - max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max - }, - extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME - } - }, - user: { - videoQuota: CONFIG.USER.VIDEO_QUOTA, - videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY - }, - trending: { - videos: { - intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS, - algorithms: { - enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED, - default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT - } - } - }, - tracker: { - enabled: CONFIG.TRACKER.ENABLED - }, - - followings: { - instance: { - autoFollowIndex: { - indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL - } - } - }, - - broadcastMessage: { - enabled: CONFIG.BROADCAST_MESSAGE.ENABLED, - message: CONFIG.BROADCAST_MESSAGE.MESSAGE, - level: CONFIG.BROADCAST_MESSAGE.LEVEL, - dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE - } - } -} - -function getRegisteredThemes () { - return PluginManager.Instance.getRegisteredThemes() - .map(t => ({ - name: t.name, - version: t.version, - description: t.description, - css: t.css, - clientScripts: t.clientScripts - })) -} - -function getRegisteredPlugins () { - return PluginManager.Instance.getRegisteredPlugins() - .map(p => ({ - name: p.name, - version: p.version, - description: p.description, - clientScripts: p.clientScripts - })) -} - -function getEnabledResolutions (type: 'vod' | 'live') { - const transcoding = type === 'vod' - ? CONFIG.TRANSCODING - : CONFIG.LIVE.TRANSCODING - - return Object.keys(transcoding.RESOLUTIONS) - .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true) - .map(r => parseInt(r, 10)) -} - -// --------------------------------------------------------------------------- - -export { - getServerConfig, - getRegisteredThemes, - getEnabledResolutions, - getRegisteredPlugins, - getHTMLServerConfig -} - -// --------------------------------------------------------------------------- - -function getIdAndPassAuthPlugins () { - const result: RegisteredIdAndPassAuthConfig[] = [] - - for (const p of PluginManager.Instance.getIdAndPassAuths()) { - for (const auth of p.idAndPassAuths) { - result.push({ - npmName: p.npmName, - name: p.name, - version: p.version, - authName: auth.authName, - weight: auth.getWeight() - }) - } - } - - return result -} - -function getExternalAuthsPlugins () { - const result: RegisteredExternalAuthConfig[] = [] - - for (const p of PluginManager.Instance.getExternalAuths()) { - for (const auth of p.externalAuths) { - result.push({ - npmName: p.npmName, - name: p.name, - version: p.version, - authName: auth.authName, - authDisplayName: auth.authDisplayName() - }) - } - } - - return result -} diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 3067ce214..d71053e87 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -2,8 +2,10 @@ import * as Bull from 'bull' import { move, remove, stat } from 'fs-extra' import { extname } from 'path' import { retryTransactionWrapper } from '@server/helpers/database-utils' +import { YoutubeDL } from '@server/helpers/youtube-dl' import { isPostImportVideoAccepted } from '@server/lib/moderation' import { Hooks } from '@server/lib/plugins/hooks' +import { ServerConfigManager } from '@server/lib/server-config-manager' import { isAbleToUploadVideo } from '@server/lib/user' import { addOptimizeOrMergeAudioJob } from '@server/lib/video' import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' @@ -33,8 +35,6 @@ import { MThumbnail } from '../../../types/models/video/thumbnail' import { federateVideoIfNeeded } from '../../activitypub/videos' import { Notifier } from '../../notifier' import { generateVideoMiniature } from '../../thumbnail' -import { YoutubeDL } from '@server/helpers/youtube-dl' -import { getEnabledResolutions } from '@server/lib/config' async function processVideoImport (job: Bull.Job) { const payload = job.data as VideoImportPayload @@ -76,7 +76,7 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub videoImportId: videoImport.id } - const youtubeDL = new YoutubeDL(videoImport.targetUrl, getEnabledResolutions('vod')) + const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod')) return processFile( () => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT), diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts index cb1cd4d9a..8487672ba 100644 --- a/server/lib/plugins/plugin-helpers-builder.ts +++ b/server/lib/plugins/plugin-helpers-builder.ts @@ -15,7 +15,7 @@ import { MPlugin } from '@server/types/models' import { PeerTubeHelpers } from '@server/types/plugins' import { VideoBlacklistCreate } from '@shared/models' import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist' -import { getServerConfig } from '../config' +import { ServerConfigManager } from '../server-config-manager' import { blacklistVideo, unblacklistVideo } from '../video-blacklist' import { UserModel } from '@server/models/user/user' @@ -147,7 +147,7 @@ function buildConfigHelpers () { }, getServerConfig () { - return getServerConfig() + return ServerConfigManager.Instance.getServerConfig() } } } diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts new file mode 100644 index 000000000..1aff6f446 --- /dev/null +++ b/server/lib/server-config-manager.ts @@ -0,0 +1,303 @@ +import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup' +import { getServerCommit } from '@server/helpers/utils' +import { CONFIG, isEmailEnabled } from '@server/initializers/config' +import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants' +import { ActorCustomPageModel } from '@server/models/account/actor-custom-page' +import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models' +import { Hooks } from './plugins/hooks' +import { PluginManager } from './plugins/plugin-manager' +import { getThemeOrDefault } from './plugins/theme-utils' +import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles' + +/** + * + * Used to send the server config to clients (using REST/API or plugins API) + * We need a singleton class to manage config state depending on external events (to build menu entries etc) + * + */ + +class ServerConfigManager { + + private static instance: ServerConfigManager + + private serverCommit: string + + private homepageEnabled = false + + private constructor () {} + + async init () { + const instanceHomepage = await ActorCustomPageModel.loadInstanceHomepage() + + this.updateHomepageState(instanceHomepage?.content) + } + + updateHomepageState (content: string) { + this.homepageEnabled = !!content + } + + async getHTMLServerConfig (): Promise { + if (this.serverCommit === undefined) this.serverCommit = await getServerCommit() + + const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) + + return { + instance: { + name: CONFIG.INSTANCE.NAME, + shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION, + isNSFW: CONFIG.INSTANCE.IS_NSFW, + defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, + defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE, + customizations: { + javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT, + css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS + } + }, + search: { + remoteUri: { + users: CONFIG.SEARCH.REMOTE_URI.USERS, + anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS + }, + searchIndex: { + enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED, + url: CONFIG.SEARCH.SEARCH_INDEX.URL, + disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH, + isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH + } + }, + plugin: { + registered: this.getRegisteredPlugins(), + registeredExternalAuths: this.getExternalAuthsPlugins(), + registeredIdAndPassAuths: this.getIdAndPassAuthPlugins() + }, + theme: { + registered: this.getRegisteredThemes(), + default: defaultTheme + }, + email: { + enabled: isEmailEnabled() + }, + contactForm: { + enabled: CONFIG.CONTACT_FORM.ENABLED + }, + serverVersion: PEERTUBE_VERSION, + serverCommit: this.serverCommit, + transcoding: { + hls: { + enabled: CONFIG.TRANSCODING.HLS.ENABLED + }, + webtorrent: { + enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED + }, + enabledResolutions: this.getEnabledResolutions('vod'), + profile: CONFIG.TRANSCODING.PROFILE, + availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod') + }, + live: { + enabled: CONFIG.LIVE.ENABLED, + + allowReplay: CONFIG.LIVE.ALLOW_REPLAY, + maxDuration: CONFIG.LIVE.MAX_DURATION, + maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, + maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, + + transcoding: { + enabled: CONFIG.LIVE.TRANSCODING.ENABLED, + enabledResolutions: this.getEnabledResolutions('live'), + profile: CONFIG.LIVE.TRANSCODING.PROFILE, + availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live') + }, + + rtmp: { + port: CONFIG.LIVE.RTMP.PORT + } + }, + import: { + videos: { + http: { + enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED + }, + torrent: { + enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED + } + } + }, + avatar: { + file: { + size: { + max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max + }, + extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME + } + }, + banner: { + file: { + size: { + max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max + }, + extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME + } + }, + video: { + image: { + extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME, + size: { + max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max + } + }, + file: { + extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME + } + }, + videoCaption: { + file: { + size: { + max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max + }, + extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME + } + }, + user: { + videoQuota: CONFIG.USER.VIDEO_QUOTA, + videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY + }, + trending: { + videos: { + intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS, + algorithms: { + enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED, + default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT + } + } + }, + tracker: { + enabled: CONFIG.TRACKER.ENABLED + }, + + followings: { + instance: { + autoFollowIndex: { + indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL + } + } + }, + + broadcastMessage: { + enabled: CONFIG.BROADCAST_MESSAGE.ENABLED, + message: CONFIG.BROADCAST_MESSAGE.MESSAGE, + level: CONFIG.BROADCAST_MESSAGE.LEVEL, + dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE + }, + + homepage: { + enabled: this.homepageEnabled + } + } + } + + async getServerConfig (ip?: string): Promise { + const { allowed } = await Hooks.wrapPromiseFun( + isSignupAllowed, + { + ip + }, + 'filter:api.user.signup.allowed.result' + ) + + const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) + + const signup = { + allowed, + allowedForCurrentIP, + requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION + } + + const htmlConfig = await this.getHTMLServerConfig() + + return { ...htmlConfig, signup } + } + + getRegisteredThemes () { + return PluginManager.Instance.getRegisteredThemes() + .map(t => ({ + name: t.name, + version: t.version, + description: t.description, + css: t.css, + clientScripts: t.clientScripts + })) + } + + getRegisteredPlugins () { + return PluginManager.Instance.getRegisteredPlugins() + .map(p => ({ + name: p.name, + version: p.version, + description: p.description, + clientScripts: p.clientScripts + })) + } + + getEnabledResolutions (type: 'vod' | 'live') { + const transcoding = type === 'vod' + ? CONFIG.TRANSCODING + : CONFIG.LIVE.TRANSCODING + + return Object.keys(transcoding.RESOLUTIONS) + .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true) + .map(r => parseInt(r, 10)) + } + + private getIdAndPassAuthPlugins () { + const result: RegisteredIdAndPassAuthConfig[] = [] + + for (const p of PluginManager.Instance.getIdAndPassAuths()) { + for (const auth of p.idAndPassAuths) { + result.push({ + npmName: p.npmName, + name: p.name, + version: p.version, + authName: auth.authName, + weight: auth.getWeight() + }) + } + } + + return result + } + + private getExternalAuthsPlugins () { + const result: RegisteredExternalAuthConfig[] = [] + + for (const p of PluginManager.Instance.getExternalAuths()) { + for (const auth of p.externalAuths) { + result.push({ + npmName: p.npmName, + name: p.name, + version: p.version, + authName: auth.authName, + authDisplayName: auth.authDisplayName() + }) + } + } + + return result + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + ServerConfigManager +} diff --git a/server/models/account/actor-custom-page.ts b/server/models/account/actor-custom-page.ts new file mode 100644 index 000000000..893023181 --- /dev/null +++ b/server/models/account/actor-custom-page.ts @@ -0,0 +1,69 @@ +import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { CustomPage } from '@shared/models' +import { ActorModel } from '../actor/actor' +import { getServerActor } from '../application/application' + +@Table({ + tableName: 'actorCustomPage', + indexes: [ + { + fields: [ 'actorId', 'type' ], + unique: true + } + ] +}) +export class ActorCustomPageModel extends Model { + + @AllowNull(true) + @Column(DataType.TEXT) + content: string + + @AllowNull(false) + @Column + type: 'homepage' + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => ActorModel) + @Column + actorId: number + + @BelongsTo(() => ActorModel, { + foreignKey: { + name: 'actorId', + allowNull: false + }, + onDelete: 'cascade' + }) + Actor: ActorModel + + static async updateInstanceHomepage (content: string) { + const serverActor = await getServerActor() + + return ActorCustomPageModel.upsert({ + content, + actorId: serverActor.id, + type: 'homepage' + }) + } + + static async loadInstanceHomepage () { + const serverActor = await getServerActor() + + return ActorCustomPageModel.findOne({ + where: { + actorId: serverActor.id + } + }) + } + + toFormattedJSON (): CustomPage { + return { + content: this.content + } + } +} diff --git a/server/tests/api/check-params/custom-pages.ts b/server/tests/api/check-params/custom-pages.ts new file mode 100644 index 000000000..74ca3384c --- /dev/null +++ b/server/tests/api/check-params/custom-pages.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' +import { + cleanupTests, + createUser, + flushAndRunServer, + ServerInfo, + setAccessTokensToServers, + userLogin +} from '../../../../shared/extra-utils' +import { makeGetRequest, makePutBodyRequest } from '../../../../shared/extra-utils/requests/requests' + +describe('Test custom pages validators', function () { + const path = '/api/v1/custom-pages/homepage/instance' + + let server: ServerInfo + let userAccessToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await flushAndRunServer(1) + await setAccessTokensToServers([ server ]) + + const user = { username: 'user1', password: 'password' } + await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password }) + + userAccessToken = await userLogin(server, user) + }) + + describe('When updating instance homepage', function () { + + it('Should fail with an unauthenticated user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + fields: { content: 'super content' }, + statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: userAccessToken, + fields: { content: 'super content' }, + statusCodeExpected: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { content: 'super content' }, + statusCodeExpected: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When getting instance homapage', function () { + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + statusCodeExpected: HttpStatusCode.OK_200 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 143515838..ce2335e42 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -3,6 +3,7 @@ import './accounts' import './blocklist' import './bulk' import './config' +import './custom-pages' import './contact-form' import './debug' import './follows' diff --git a/server/tests/api/server/homepage.ts b/server/tests/api/server/homepage.ts new file mode 100644 index 000000000..e8ba89ca6 --- /dev/null +++ b/server/tests/api/server/homepage.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import * as chai from 'chai' +import { HttpStatusCode } from '@shared/core-utils' +import { CustomPage, ServerConfig } from '@shared/models' +import { + cleanupTests, + flushAndRunServer, + getConfig, + getInstanceHomepage, + killallServers, + reRunServer, + ServerInfo, + setAccessTokensToServers, + updateInstanceHomepage +} from '../../../../shared/extra-utils/index' + +const expect = chai.expect + +async function getHomepageState (server: ServerInfo) { + const res = await getConfig(server.url) + + const config = res.body as ServerConfig + return config.homepage.enabled +} + +describe('Test instance homepage actions', function () { + let server: ServerInfo + + before(async function () { + this.timeout(30000) + + server = await flushAndRunServer(1) + await setAccessTokensToServers([ server ]) + }) + + it('Should not have a homepage', async function () { + const state = await getHomepageState(server) + expect(state).to.be.false + + await getInstanceHomepage(server.url, HttpStatusCode.NOT_FOUND_404) + }) + + it('Should set a homepage', async function () { + await updateInstanceHomepage(server.url, server.accessToken, '') + + const res = await getInstanceHomepage(server.url) + const page: CustomPage = res.body + expect(page.content).to.equal('') + + const state = await getHomepageState(server) + expect(state).to.be.true + }) + + it('Should have the same homepage after a restart', async function () { + this.timeout(30000) + + killallServers([ server ]) + + await reRunServer(server) + + const res = await getInstanceHomepage(server.url) + const page: CustomPage = res.body + expect(page.content).to.equal('') + + const state = await getHomepageState(server) + expect(state).to.be.true + }) + + it('Should empty the homepage', async function () { + await updateInstanceHomepage(server.url, server.accessToken, '') + + const res = await getInstanceHomepage(server.url) + const page: CustomPage = res.body + expect(page.content).to.be.empty + + const state = await getHomepageState(server) + expect(state).to.be.false + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts index be743973a..56e6eb5da 100644 --- a/server/tests/api/server/index.ts +++ b/server/tests/api/server/index.ts @@ -5,6 +5,7 @@ import './email' import './follow-constraints' import './follows' import './follows-moderation' +import './homepage' import './handle-down' import './jobs' import './logs' diff --git a/server/types/models/account/actor-custom-page.ts b/server/types/models/account/actor-custom-page.ts new file mode 100644 index 000000000..2cb8aa7e4 --- /dev/null +++ b/server/types/models/account/actor-custom-page.ts @@ -0,0 +1,4 @@ + +import { ActorCustomPageModel } from '../../../models/account/actor-custom-page' + +export type MActorCustomPage = Omit diff --git a/server/types/models/account/index.ts b/server/types/models/account/index.ts index dab2eea7e..9679c01e4 100644 --- a/server/types/models/account/index.ts +++ b/server/types/models/account/index.ts @@ -1,2 +1,3 @@ export * from './account' +export * from './actor-custom-page' export * from './account-blocklist' diff --git a/shared/core-utils/miscs/miscs.ts b/shared/core-utils/miscs/miscs.ts index 71703faac..4780ca922 100644 --- a/shared/core-utils/miscs/miscs.ts +++ b/shared/core-utils/miscs/miscs.ts @@ -28,9 +28,24 @@ function isCatchable (value: any) { return value && typeof value.catch === 'function' } +function sortObjectComparator (key: string, order: 'asc' | 'desc') { + return (a: any, b: any) => { + if (a[key] < b[key]) { + return order === 'asc' ? -1 : 1 + } + + if (a[key] > b[key]) { + return order === 'asc' ? 1 : -1 + } + + return 0 + } +} + export { randomInt, compareSemVer, isPromise, - isCatchable + isCatchable, + sortObjectComparator } diff --git a/shared/core-utils/renderer/html.ts b/shared/core-utils/renderer/html.ts index de4ad47ac..bbf8b3fbd 100644 --- a/shared/core-utils/renderer/html.ts +++ b/shared/core-utils/renderer/html.ts @@ -1,25 +1,45 @@ -export const SANITIZE_OPTIONS = { - allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], - allowedSchemes: [ 'http', 'https' ], - allowedAttributes: { - a: [ 'href', 'class', 'target', 'rel' ] - }, - transformTags: { - a: (tagName: string, attribs: any) => { - let rel = 'noopener noreferrer' - if (attribs.rel === 'me') rel += ' me' +export function getSanitizeOptions () { + return { + allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], + allowedSchemes: [ 'http', 'https' ], + allowedAttributes: { + 'a': [ 'href', 'class', 'target', 'rel' ], + '*': [ 'data-*' ] + }, + transformTags: { + a: (tagName: string, attribs: any) => { + let rel = 'noopener noreferrer' + if (attribs.rel === 'me') rel += ' me' - return { - tagName, - attribs: Object.assign(attribs, { - target: '_blank', - rel - }) + return { + tagName, + attribs: Object.assign(attribs, { + target: '_blank', + rel + }) + } } } } } +export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) { + const base = getSanitizeOptions() + + return { + allowedTags: [ + ...base.allowedTags, + ...additionalAllowedTags, + 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' + ], + allowedSchemes: base.allowedSchemes, + allowedAttributes: { + ...base.allowedAttributes, + '*': [ 'data-*', 'style' ] + } + } +} + // Thanks: https://stackoverflow.com/a/12034334 export function escapeHTML (stringParam: string) { if (!stringParam) return '' diff --git a/shared/extra-utils/custom-pages/custom-pages.ts b/shared/extra-utils/custom-pages/custom-pages.ts new file mode 100644 index 000000000..bf2d16c70 --- /dev/null +++ b/shared/extra-utils/custom-pages/custom-pages.ts @@ -0,0 +1,31 @@ +import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' +import { makeGetRequest, makePutBodyRequest } from '../requests/requests' + +function getInstanceHomepage (url: string, statusCodeExpected = HttpStatusCode.OK_200) { + const path = '/api/v1/custom-pages/homepage/instance' + + return makeGetRequest({ + url, + path, + statusCodeExpected + }) +} + +function updateInstanceHomepage (url: string, token: string, content: string) { + const path = '/api/v1/custom-pages/homepage/instance' + + return makePutBodyRequest({ + url, + path, + token, + fields: { content }, + statusCodeExpected: HttpStatusCode.NO_CONTENT_204 + }) +} + +// --------------------------------------------------------------------------- + +export { + getInstanceHomepage, + updateInstanceHomepage +} diff --git a/shared/extra-utils/index.ts b/shared/extra-utils/index.ts index 720db19cb..3bc09ead5 100644 --- a/shared/extra-utils/index.ts +++ b/shared/extra-utils/index.ts @@ -2,6 +2,8 @@ export * from './bulk/bulk' export * from './cli/cli' +export * from './custom-pages/custom-pages' + export * from './feeds/feeds' export * from './mock-servers/mock-instances-index' diff --git a/shared/models/actors/custom-page.model.ts b/shared/models/actors/custom-page.model.ts new file mode 100644 index 000000000..1e33584c1 --- /dev/null +++ b/shared/models/actors/custom-page.model.ts @@ -0,0 +1,3 @@ +export interface CustomPage { + content: string +} diff --git a/shared/models/actors/index.ts b/shared/models/actors/index.ts index 156f83248..e03f168cd 100644 --- a/shared/models/actors/index.ts +++ b/shared/models/actors/index.ts @@ -2,4 +2,5 @@ export * from './account.model' export * from './actor-image.model' export * from './actor-image.type' export * from './actor.model' +export * from './custom-page.model' export * from './follow.model' diff --git a/shared/models/custom-markup/custom-markup-data.model.ts b/shared/models/custom-markup/custom-markup-data.model.ts new file mode 100644 index 000000000..af697428e --- /dev/null +++ b/shared/models/custom-markup/custom-markup-data.model.ts @@ -0,0 +1,28 @@ +export type EmbedMarkupData = { + // Video or playlist uuid + uuid: string +} + +export type VideoMiniatureMarkupData = { + // Video uuid + uuid: string +} + +export type PlaylistMiniatureMarkupData = { + // Playlist uuid + uuid: string +} + +export type ChannelMiniatureMarkupData = { + // Channel name (username) + name: string +} + +export type VideosListMarkupData = { + title: string + description: string + sort: string + categoryOneOf: string // coma separated values + languageOneOf: string // coma separated values + count: string +} diff --git a/shared/models/custom-markup/index.ts b/shared/models/custom-markup/index.ts new file mode 100644 index 000000000..2898dfa90 --- /dev/null +++ b/shared/models/custom-markup/index.ts @@ -0,0 +1 @@ +export * from './custom-markup-data.model' diff --git a/shared/models/index.ts b/shared/models/index.ts index dff5fdf0e..4db1f234e 100644 --- a/shared/models/index.ts +++ b/shared/models/index.ts @@ -1,6 +1,7 @@ export * from './activitypub' export * from './actors' export * from './moderation' +export * from './custom-markup' export * from './bulk' export * from './redundancy' export * from './users' diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index 2c5026b30..1667bc0e2 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -214,6 +214,10 @@ export interface ServerConfig { level: BroadcastMessageLevel dismissable: boolean } + + homepage: { + enabled: boolean + } } export type HTMLServerConfig = Omit diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index bbedc9f00..950b22bad 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts @@ -16,6 +16,7 @@ export const enum UserRight { MANAGE_JOBS, MANAGE_CONFIGURATION, + MANAGE_INSTANCE_CUSTOM_PAGE, MANAGE_ACCOUNTS_BLOCKLIST, MANAGE_SERVERS_BLOCKLIST, diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 11adf078d..74910c313 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -247,6 +247,8 @@ tags: Administrators can also enable the use of a remote search system, indexing videos and channels not could be not federated by the instance. + - name: Homepage + description: Get and update the custom homepage - name: Video Mirroring description: | PeerTube instances can mirror videos from one another, and help distribute some videos. @@ -281,6 +283,9 @@ x-tagGroups: - name: Search tags: - Search + - name: Custom pages + tags: + - Homepage - name: Moderation tags: - Abuses @@ -477,6 +482,40 @@ paths: '200': description: successful operation + /custom-pages/homepage/instance: + get: + summary: Get instance custom homepage + tags: + - Homepage + responses: + '404': + description: No homepage set + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/CustomHomepage' + put: + summary: Set instance custom homepage + tags: + - Homepage + security: + - OAuth2: + - admin + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + description: content of the homepage, that will be injected in the client + responses: + '204': + description: successful operation + /jobs/{state}: get: summary: List instance jobs @@ -5740,6 +5779,12 @@ components: indexUrl: type: string format: url + homepage: + type: object + properties: + enabled: + type: boolean + ServerConfigAbout: properties: instance: @@ -5930,6 +5975,12 @@ components: type: boolean manualApproval: type: boolean + + CustomHomepage: + properties: + content: + type: string + Follow: properties: id: