mirror of https://github.com/Chocobozzz/PeerTube
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 servicepull/4105/head^2
parent
eb34ec30e0
commit
2539932e16
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -26,22 +26,13 @@
|
|||
<div class="form-group" formGroupName="instance">
|
||||
<label i18n for="instanceDefaultClientRoute">Landing page</label>
|
||||
|
||||
<div class="peertube-select-container">
|
||||
<select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control">
|
||||
<option i18n value="/videos/overview">Discover videos</option>
|
||||
|
||||
<optgroup i18n-label label="Trending pages">
|
||||
<option i18n value="/videos/trending">Default trending page</option>
|
||||
<option i18n value="/videos/trending?alg=best" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('best')">Best videos</option>
|
||||
<option i18n value="/videos/trending?alg=hot" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('hot')">Hot videos</option>
|
||||
<option i18n value="/videos/trending?alg=most-viewed" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-viewed')">Most viewed videos</option>
|
||||
<option i18n value="/videos/trending?alg=most-liked" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-liked')">Most liked videos</option>
|
||||
</optgroup>
|
||||
|
||||
<option i18n value="/videos/recently-added">Recently added videos</option>
|
||||
<option i18n value="/videos/local">Local videos</option>
|
||||
</select>
|
||||
</div>
|
||||
<my-select-custom-value
|
||||
id="instanceDefaultClientRoute"
|
||||
[items]="defaultLandingPageOptions"
|
||||
formControlName="defaultClientRoute"
|
||||
inputType="text"
|
||||
[clearable]="false"
|
||||
></my-select-custom-value>
|
||||
|
||||
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
|
||||
</div>
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -3,8 +3,16 @@
|
|||
|
||||
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
|
||||
|
||||
<ng-container ngbNavItem="instance-homepage">
|
||||
<a ngbNavLink i18n>Homepage</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="instance-information">
|
||||
<a ngbNavLink i18n>Instance information</a>
|
||||
<a ngbNavLink i18n>Information</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
|
||||
|
@ -13,7 +21,7 @@
|
|||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="basic-configuration">
|
||||
<a ngbNavLink i18n>Basic configuration</a>
|
||||
<a ngbNavLink i18n>Basic</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
|
||||
|
@ -40,7 +48,7 @@
|
|||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="advanced-configuration">
|
||||
<a ngbNavLink i18n>Advanced configuration</a>
|
||||
<a ngbNavLink i18n>Advanced</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
import omit from 'lodash-es/omit'
|
||||
import { forkJoin } from 'rxjs'
|
||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
|
@ -24,9 +25,14 @@ import {
|
|||
} from '@app/shared/form-validators/custom-config-validators'
|
||||
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
|
||||
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
|
||||
import { CustomConfig, ServerConfig } from '@shared/models'
|
||||
import { CustomPageService } from '@app/shared/shared-main/custom-page'
|
||||
import { CustomConfig, CustomPage, ServerConfig } from '@shared/models'
|
||||
import { EditConfigurationService } from './edit-configuration.service'
|
||||
|
||||
type ComponentCustomConfig = CustomConfig & {
|
||||
instanceCustomHomepage: CustomPage
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'my-edit-custom-config',
|
||||
templateUrl: './edit-custom-config.component.html',
|
||||
|
@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service'
|
|||
export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||
activeNav: string
|
||||
|
||||
customConfig: CustomConfig
|
||||
customConfig: ComponentCustomConfig
|
||||
serverConfig: ServerConfig
|
||||
|
||||
homepage: CustomPage
|
||||
|
||||
languageItems: SelectOptionsItem[] = []
|
||||
categoryItems: SelectOptionsItem[] = []
|
||||
|
||||
|
@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
|||
protected formValidatorService: FormValidatorService,
|
||||
private notifier: Notifier,
|
||||
private configService: ConfigService,
|
||||
private customPage: CustomPageService,
|
||||
private serverService: ServerService,
|
||||
private editConfigurationService: EditConfigurationService
|
||||
) {
|
||||
|
@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
|||
ngOnInit () {
|
||||
this.serverConfig = this.serverService.getTmpConfig()
|
||||
this.serverService.getConfig()
|
||||
.subscribe(config => {
|
||||
this.serverConfig = config
|
||||
})
|
||||
.subscribe(config => this.serverConfig = config)
|
||||
|
||||
const formGroupData: { [key in keyof CustomConfig ]: any } = {
|
||||
const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
|
||||
instance: {
|
||||
name: INSTANCE_NAME_VALIDATOR,
|
||||
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
|
||||
|
@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
|||
disableLocalSearch: null,
|
||||
isDefaultSearch: null
|
||||
}
|
||||
},
|
||||
|
||||
instanceCustomHomepage: {
|
||||
content: null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
|||
}
|
||||
|
||||
async formValidated () {
|
||||
const value: CustomConfig = this.form.getRawValue()
|
||||
const value: ComponentCustomConfig = this.form.getRawValue()
|
||||
|
||||
this.configService.updateCustomConfig(value)
|
||||
forkJoin([
|
||||
this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
|
||||
this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
|
||||
])
|
||||
.subscribe(
|
||||
res => {
|
||||
this.customConfig = res
|
||||
([ resConfig ]) => {
|
||||
const instanceCustomHomepage = {
|
||||
content: value.instanceCustomHomepage.content
|
||||
}
|
||||
|
||||
this.customConfig = { ...resConfig, instanceCustomHomepage }
|
||||
|
||||
// Reload general configuration
|
||||
this.serverService.resetConfig()
|
||||
.subscribe(config => this.serverConfig = config)
|
||||
|
||||
this.updateForm()
|
||||
|
||||
|
@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
|||
}
|
||||
|
||||
private loadConfigAndUpdateForm () {
|
||||
this.configService.getCustomConfig()
|
||||
.subscribe(config => {
|
||||
this.customConfig = config
|
||||
forkJoin([
|
||||
this.configService.getCustomConfig(),
|
||||
this.customPage.getInstanceHomepage()
|
||||
])
|
||||
.subscribe(([ config, homepage ]) => {
|
||||
this.customConfig = { ...config, instanceCustomHomepage: homepage }
|
||||
|
||||
this.updateForm()
|
||||
// Force form validation
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ export * from './edit-advanced-configuration.component'
|
|||
export * from './edit-basic-configuration.component'
|
||||
export * from './edit-configuration.service'
|
||||
export * from './edit-custom-config.component'
|
||||
export * from './edit-homepage.component'
|
||||
export * from './edit-instance-information.component'
|
||||
export * from './edit-live-configuration.component'
|
||||
export * from './edit-vod-transcoding.component'
|
||||
|
|
|
@ -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 {}
|
|
@ -0,0 +1,4 @@
|
|||
<div class="root margin-content">
|
||||
<div #contentWrapper></div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
padding-top: 20px;
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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 { }
|
|
@ -0,0 +1,3 @@
|
|||
export * from './home-routing.module'
|
||||
export * from './home.component'
|
||||
export * from './home.module'
|
|
@ -161,7 +161,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
|||
// Before HTML rendering restore line feed for markdown list compatibility
|
||||
const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
|
||||
const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
|
||||
this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
|
||||
this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html)
|
||||
this.newParentComments = this.parentComments.concat([ this.comment ])
|
||||
|
||||
if (this.comment.account) {
|
||||
|
|
|
@ -509,7 +509,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
private async setVideoDescriptionHTML () {
|
||||
const html = await this.markdownService.textMarkdownToHTML(this.video.description)
|
||||
this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
|
||||
this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html)
|
||||
}
|
||||
|
||||
private setVideoLikesBarTooltipText () {
|
||||
|
|
|
@ -13,6 +13,10 @@ const routes: Routes = [
|
|||
canDeactivate: [ MenuGuards.open() ],
|
||||
loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./+home/home.module').then(m => m.HomeModule)
|
||||
},
|
||||
{
|
||||
path: 'my-account',
|
||||
loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule)
|
||||
|
|
|
@ -231,7 +231,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
}
|
||||
|
||||
this.broadcastMessage = {
|
||||
message: await this.markdownService.completeMarkdownToHTML(messageConfig.message),
|
||||
message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true),
|
||||
dismissable: messageConfig.dismissable,
|
||||
class: classes[messageConfig.level]
|
||||
}
|
||||
|
|
|
@ -1,8 +1,19 @@
|
|||
import { fromEvent } from 'rxjs'
|
||||
import { debounceTime } from 'rxjs/operators'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { GlobalIconName } from '@app/shared/shared-icons'
|
||||
import { sortObjectComparator } from '@shared/core-utils/miscs/miscs'
|
||||
import { ServerConfig } from '@shared/models/server'
|
||||
import { ScreenService } from '../wrappers'
|
||||
|
||||
export type MenuLink = {
|
||||
icon: GlobalIconName
|
||||
label: string
|
||||
menuLabel: string
|
||||
path: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MenuService {
|
||||
isMenuDisplayed = true
|
||||
|
@ -48,6 +59,53 @@ export class MenuService {
|
|||
this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
|
||||
}
|
||||
|
||||
buildCommonLinks (config: ServerConfig) {
|
||||
let entries: MenuLink[] = [
|
||||
{
|
||||
icon: 'globe' as 'globe',
|
||||
label: $localize`Discover videos`,
|
||||
menuLabel: $localize`Discover`,
|
||||
path: '/videos/overview',
|
||||
priority: 150
|
||||
},
|
||||
{
|
||||
icon: 'trending' as 'trending',
|
||||
label: $localize`Trending videos`,
|
||||
menuLabel: $localize`Trending`,
|
||||
path: '/videos/trending',
|
||||
priority: 140
|
||||
},
|
||||
{
|
||||
icon: 'recently-added' as 'recently-added',
|
||||
label: $localize`Recently added videos`,
|
||||
menuLabel: $localize`Recently added`,
|
||||
path: '/videos/recently-added',
|
||||
priority: 130
|
||||
},
|
||||
{
|
||||
icon: 'octagon' as 'octagon',
|
||||
label: $localize`Local videos`,
|
||||
menuLabel: $localize`Local videos`,
|
||||
path: '/videos/local',
|
||||
priority: 120
|
||||
}
|
||||
]
|
||||
|
||||
if (config.homepage.enabled) {
|
||||
entries.push({
|
||||
icon: 'home' as 'home',
|
||||
label: $localize`Home`,
|
||||
menuLabel: $localize`Home`,
|
||||
path: '/home',
|
||||
priority: 160
|
||||
})
|
||||
}
|
||||
|
||||
entries = entries.sort(sortObjectComparator('priority', 'desc'))
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
private handleWindowResize () {
|
||||
// On touch screens, do not handle window resize event since opened menu is handled with a content overlay
|
||||
if (this.screenService.isInTouchScreen()) return
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@angular/core'
|
||||
import { LinkifierService } from './linkifier.service'
|
||||
import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html'
|
||||
import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html'
|
||||
|
||||
@Injectable()
|
||||
export class HtmlRendererService {
|
||||
|
@ -20,7 +20,7 @@ export class HtmlRendererService {
|
|||
})
|
||||
}
|
||||
|
||||
async toSafeHtml (text: string) {
|
||||
async toSafeHtml (text: string, additionalAllowedTags: string[] = []) {
|
||||
const [ html ] = await Promise.all([
|
||||
// Convert possible markdown to html
|
||||
this.linkifier.linkify(text),
|
||||
|
@ -28,7 +28,11 @@ export class HtmlRendererService {
|
|||
this.loadSanitizeHtml()
|
||||
])
|
||||
|
||||
return this.sanitizeHtml(html, SANITIZE_OPTIONS)
|
||||
const options = additionalAllowedTags.length !== 0
|
||||
? getCustomMarkupSanitizeOptions(additionalAllowedTags)
|
||||
: getSanitizeOptions()
|
||||
|
||||
return this.sanitizeHtml(html, options)
|
||||
}
|
||||
|
||||
private async loadSanitizeHtml () {
|
||||
|
|
|
@ -17,12 +17,15 @@ type MarkdownParsers = {
|
|||
enhancedMarkdownIt: MarkdownIt
|
||||
enhancedWithHTMLMarkdownIt: MarkdownIt
|
||||
|
||||
completeMarkdownIt: MarkdownIt
|
||||
unsafeMarkdownIt: MarkdownIt
|
||||
|
||||
customPageMarkdownIt: MarkdownIt
|
||||
}
|
||||
|
||||
type MarkdownConfig = {
|
||||
rules: string[]
|
||||
html: boolean
|
||||
breaks: boolean
|
||||
escape?: boolean
|
||||
}
|
||||
|
||||
|
@ -35,18 +38,24 @@ export class MarkdownService {
|
|||
private markdownParsers: MarkdownParsers = {
|
||||
textMarkdownIt: null,
|
||||
textWithHTMLMarkdownIt: null,
|
||||
|
||||
enhancedMarkdownIt: null,
|
||||
enhancedWithHTMLMarkdownIt: null,
|
||||
completeMarkdownIt: null
|
||||
|
||||
unsafeMarkdownIt: null,
|
||||
|
||||
customPageMarkdownIt: null
|
||||
}
|
||||
private parsersConfig: MarkdownParserConfigs = {
|
||||
textMarkdownIt: { rules: TEXT_RULES, html: false },
|
||||
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true },
|
||||
textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false },
|
||||
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true },
|
||||
|
||||
enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false },
|
||||
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true },
|
||||
enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false },
|
||||
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, breaks: true, html: true, escape: true },
|
||||
|
||||
completeMarkdownIt: { rules: COMPLETE_RULES, html: true }
|
||||
unsafeMarkdownIt: { rules: COMPLETE_RULES, breaks: true, html: true, escape: false },
|
||||
|
||||
customPageMarkdownIt: { rules: COMPLETE_RULES, breaks: false, html: true, escape: true }
|
||||
}
|
||||
|
||||
private emojiModule: any
|
||||
|
@ -54,22 +63,26 @@ export class MarkdownService {
|
|||
constructor (private htmlRenderer: HtmlRendererService) {}
|
||||
|
||||
textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
|
||||
if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown, withEmoji)
|
||||
if (withHtml) return this.render({ name: 'textWithHTMLMarkdownIt', markdown, withEmoji })
|
||||
|
||||
return this.render('textMarkdownIt', markdown, withEmoji)
|
||||
return this.render({ name: 'textMarkdownIt', markdown, withEmoji })
|
||||
}
|
||||
|
||||
enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
|
||||
if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown, withEmoji)
|
||||
if (withHtml) return this.render({ name: 'enhancedWithHTMLMarkdownIt', markdown, withEmoji })
|
||||
|
||||
return this.render('enhancedMarkdownIt', markdown, withEmoji)
|
||||
return this.render({ name: 'enhancedMarkdownIt', markdown, withEmoji })
|
||||
}
|
||||
|
||||
completeMarkdownToHTML (markdown: string) {
|
||||
return this.render('completeMarkdownIt', markdown, true)
|
||||
unsafeMarkdownToHTML (markdown: string, _trustedInput: true) {
|
||||
return this.render({ name: 'unsafeMarkdownIt', markdown, withEmoji: true })
|
||||
}
|
||||
|
||||
async processVideoTimestamps (html: string) {
|
||||
customPageMarkdownToHTML (markdown: string, additionalAllowedTags: string[]) {
|
||||
return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
|
||||
}
|
||||
|
||||
processVideoTimestamps (html: string) {
|
||||
return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
|
||||
const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
|
||||
const url = buildVideoLink({ startTime: t })
|
||||
|
@ -77,7 +90,13 @@ export class MarkdownService {
|
|||
})
|
||||
}
|
||||
|
||||
private async render (name: keyof MarkdownParsers, markdown: string, withEmoji = false) {
|
||||
private async render (options: {
|
||||
name: keyof MarkdownParsers
|
||||
markdown: string
|
||||
withEmoji: boolean
|
||||
additionalAllowedTags?: string[]
|
||||
}) {
|
||||
const { name, markdown, withEmoji, additionalAllowedTags } = options
|
||||
if (!markdown) return ''
|
||||
|
||||
const config = this.parsersConfig[ name ]
|
||||
|
@ -96,7 +115,7 @@ export class MarkdownService {
|
|||
let html = this.markdownParsers[ name ].render(markdown)
|
||||
html = this.avoidTruncatedTags(html)
|
||||
|
||||
if (config.escape) return this.htmlRenderer.toSafeHtml(html)
|
||||
if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags)
|
||||
|
||||
return html
|
||||
}
|
||||
|
@ -105,7 +124,7 @@ export class MarkdownService {
|
|||
// FIXME: import('...') returns a struct module, containing a "default" field
|
||||
const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
|
||||
|
||||
const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
|
||||
const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: config.breaks, html: config.html })
|
||||
|
||||
for (const rule of config.rules) {
|
||||
markdownIt.enable(rule)
|
||||
|
|
|
@ -173,6 +173,9 @@ export class ServerService {
|
|||
disableLocalSearch: false,
|
||||
isDefaultSearch: false
|
||||
}
|
||||
},
|
||||
homepage: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,9 +201,7 @@ export class ServerService {
|
|||
this.configReset = true
|
||||
|
||||
// Notify config update
|
||||
this.getConfig().subscribe(() => {
|
||||
// empty, to fire a reset config event
|
||||
})
|
||||
return this.getConfig()
|
||||
}
|
||||
|
||||
getConfig () {
|
||||
|
|
|
@ -123,24 +123,9 @@
|
|||
<div class="on-instance">
|
||||
<div i18n class="block-title">ON {{instanceName}}</div>
|
||||
|
||||
<a class="menu-link" routerLink="/videos/overview" routerLinkActive="active">
|
||||
<my-global-icon iconName="globe" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Discover</ng-container>
|
||||
</a>
|
||||
|
||||
<a class="menu-link" routerLink="/videos/trending" routerLinkActive="active">
|
||||
<my-global-icon iconName="trending" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Trending</ng-container>
|
||||
</a>
|
||||
|
||||
<a class="menu-link" routerLink="/videos/recently-added" routerLinkActive="active">
|
||||
<my-global-icon iconName="recently-added" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Recently added</ng-container>
|
||||
</a>
|
||||
|
||||
<a class="menu-link" routerLink="/videos/local" routerLinkActive="active">
|
||||
<my-global-icon iconName="home" aria-hidden="true"></my-global-icon>
|
||||
<ng-container i18n>Local videos</ng-container>
|
||||
<a class="menu-link" *ngFor="let commonLink of commonMenuLinks" [routerLink]="commonLink.path" routerLinkActive="active">
|
||||
<my-global-icon [iconName]="commonLink.icon" aria-hidden="true"></my-global-icon>
|
||||
<ng-container>{{ commonLink.menuLabel }}</ng-container>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators'
|
|||
import { ViewportScroller } from '@angular/common'
|
||||
import { Component, OnInit, ViewChild } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
|
||||
import {
|
||||
AuthService,
|
||||
AuthStatus,
|
||||
AuthUser,
|
||||
MenuLink,
|
||||
MenuService,
|
||||
RedirectService,
|
||||
ScreenService,
|
||||
ServerService,
|
||||
UserService
|
||||
} from '@app/core'
|
||||
import { scrollToTop } from '@app/helpers'
|
||||
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
|
||||
import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
|
||||
|
@ -35,6 +45,8 @@ export class MenuComponent implements OnInit {
|
|||
|
||||
currentInterfaceLanguage: string
|
||||
|
||||
commonMenuLinks: MenuLink[] = []
|
||||
|
||||
private languages: VideoConstant<string>[] = []
|
||||
private serverConfig: ServerConfig
|
||||
private routesPerRight: { [role in UserRight]?: string } = {
|
||||
|
@ -80,7 +92,10 @@ export class MenuComponent implements OnInit {
|
|||
ngOnInit () {
|
||||
this.serverConfig = this.serverService.getTmpConfig()
|
||||
this.serverService.getConfig()
|
||||
.subscribe(config => this.serverConfig = config)
|
||||
.subscribe(config => {
|
||||
this.serverConfig = config
|
||||
this.buildMenuLinks()
|
||||
})
|
||||
|
||||
this.isLoggedIn = this.authService.isLoggedIn()
|
||||
if (this.isLoggedIn === true) {
|
||||
|
@ -241,6 +256,10 @@ export class MenuComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
private buildMenuLinks () {
|
||||
this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig)
|
||||
}
|
||||
|
||||
private buildUserLanguages () {
|
||||
if (!this.user) {
|
||||
this.videoLanguages = []
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
.channel {
|
||||
border-radius: 15px;
|
||||
padding: 10px;
|
||||
width: min-content;
|
||||
border: 1px solid pvar(--mainColor);
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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(',')
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from './custom-markup.service'
|
||||
export * from './dynamic-element.service'
|
||||
export * from './shared-custom-markup.module'
|
|
@ -0,0 +1,2 @@
|
|||
<my-video-playlist-miniature *ngIf="playlist" [playlist]="playlist">
|
||||
</my-video-playlist-miniature>
|
|
@ -0,0 +1,7 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
my-video-playlist-miniature {
|
||||
display: inline-block;
|
||||
width: $video-thumbnail-width;
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 { }
|
|
@ -0,0 +1,6 @@
|
|||
<my-video-miniature
|
||||
*ngIf="video"
|
||||
[video]="video" [user]="getUser()" [displayAsRow]="false"
|
||||
[displayVideoActions]="false" [displayOptions]="displayOptions"
|
||||
>
|
||||
</my-video-miniature>
|
|
@ -0,0 +1,7 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
my-video-miniature {
|
||||
display: inline-block;
|
||||
width: $video-thumbnail-width;
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -19,6 +19,7 @@
|
|||
<a ngbNavLink i18n>Complete preview</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<div #previewElement></div>
|
||||
<div [innerHTML]="previewHTML"></div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { ViewportScroller } from '@angular/common'
|
||||
import truncate from 'lodash-es/truncate'
|
||||
import { Subject } from 'rxjs'
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
|
||||
import { ViewportScroller } from '@angular/common'
|
||||
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { SafeHtml } from '@angular/platform-browser'
|
||||
import { MarkdownService, ScreenService } from '@app/core'
|
||||
|
||||
@Component({
|
||||
|
@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core'
|
|||
|
||||
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
|
||||
@Input() content = ''
|
||||
|
||||
@Input() classes: string[] | { [klass: string]: any[] | any } = []
|
||||
|
||||
@Input() textareaMaxWidth = '100%'
|
||||
@Input() textareaHeight = '150px'
|
||||
|
||||
@Input() truncate: number
|
||||
|
||||
@Input() markdownType: 'text' | 'enhanced' = 'text'
|
||||
@Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
|
||||
|
||||
@Input() markdownVideo = false
|
||||
|
||||
@Input() name = 'description'
|
||||
|
||||
@ViewChild('textarea') textareaElement: ElementRef
|
||||
@ViewChild('previewElement') previewElement: ElementRef
|
||||
|
||||
truncatedPreviewHTML: SafeHtml | string = ''
|
||||
previewHTML: SafeHtml | string = ''
|
||||
|
||||
truncatedPreviewHTML = ''
|
||||
previewHTML = ''
|
||||
isMaximized = false
|
||||
|
||||
maximizeInText = $localize`Maximize editor`
|
||||
|
@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
|
|||
}
|
||||
|
||||
private async markdownRender (text: string) {
|
||||
const html = this.markdownType === 'text' ?
|
||||
await this.markdownService.textMarkdownToHTML(text) :
|
||||
await this.markdownService.enhancedMarkdownToHTML(text)
|
||||
let html: string
|
||||
|
||||
return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html
|
||||
if (this.customMarkdownRenderer) {
|
||||
const result = await this.customMarkdownRenderer(text)
|
||||
|
||||
if (result instanceof HTMLElement) {
|
||||
html = ''
|
||||
|
||||
const wrapperElement = this.previewElement.nativeElement as HTMLElement
|
||||
wrapperElement.innerHTML = ''
|
||||
wrapperElement.appendChild(result)
|
||||
return
|
||||
}
|
||||
|
||||
html = result
|
||||
} else if (this.markdownType === 'text') {
|
||||
html = await this.markdownService.textMarkdownToHTML(text)
|
||||
} else {
|
||||
html = await this.markdownService.enhancedMarkdownToHTML(text)
|
||||
}
|
||||
|
||||
if (this.markdownVideo) {
|
||||
html = this.markdownService.processVideoTimestamps(html)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './custom-page.service'
|
|
@ -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 { }
|
||||
|
|
|
@ -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 |
|
@ -95,7 +95,7 @@ function buildVideoLink (options: {
|
|||
function buildPlaylistLink (options: {
|
||||
baseUrl?: string
|
||||
|
||||
playlistPosition: number
|
||||
playlistPosition?: number
|
||||
}) {
|
||||
const { baseUrl } = options
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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$/, '')
|
||||
|
|
|
@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LAST_MIGRATION_VERSION = 645
|
||||
const LAST_MIGRATION_VERSION = 650
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 ])
|
||||
})
|
||||
})
|
|
@ -3,6 +3,7 @@ import './accounts'
|
|||
import './blocklist'
|
||||
import './bulk'
|
||||
import './config'
|
||||
import './custom-pages'
|
||||
import './contact-form'
|
||||
import './debug'
|
||||
import './follows'
|
||||
|
|
|
@ -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 ])
|
||||
})
|
||||
})
|
|
@ -5,6 +5,7 @@ import './email'
|
|||
import './follow-constraints'
|
||||
import './follows'
|
||||
import './follows-moderation'
|
||||
import './homepage'
|
||||
import './handle-down'
|
||||
import './jobs'
|
||||
import './logs'
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
import { ActorCustomPageModel } from '../../../models/account/actor-custom-page'
|
||||
|
||||
export type MActorCustomPage = Omit<ActorCustomPageModel, 'Actor'>
|
|
@ -1,2 +1,3 @@
|
|||
export * from './account'
|
||||
export * from './actor-custom-page'
|
||||
export * from './account-blocklist'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
export const SANITIZE_OPTIONS = {
|
||||
export function getSanitizeOptions () {
|
||||
return {
|
||||
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
|
||||
allowedSchemes: [ 'http', 'https' ],
|
||||
allowedAttributes: {
|
||||
a: [ 'href', 'class', 'target', 'rel' ]
|
||||
'a': [ 'href', 'class', 'target', 'rel' ],
|
||||
'*': [ 'data-*' ]
|
||||
},
|
||||
transformTags: {
|
||||
a: (tagName: string, attribs: any) => {
|
||||
|
@ -18,6 +20,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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export interface CustomPage {
|
||||
content: string
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './custom-markup-data.model'
|
|
@ -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'
|
||||
|
|
|
@ -214,6 +214,10 @@ export interface ServerConfig {
|
|||
level: BroadcastMessageLevel
|
||||
dismissable: boolean
|
||||
}
|
||||
|
||||
homepage: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type HTMLServerConfig = Omit<ServerConfig, 'signup'>
|
||||
|
|
|
@ -16,6 +16,7 @@ export const enum UserRight {
|
|||
MANAGE_JOBS,
|
||||
|
||||
MANAGE_CONFIGURATION,
|
||||
MANAGE_INSTANCE_CUSTOM_PAGE,
|
||||
|
||||
MANAGE_ACCOUNTS_BLOCKLIST,
|
||||
MANAGE_SERVERS_BLOCKLIST,
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue