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) { }
|
constructor (private markdownService: MarkdownService) { }
|
||||||
|
|
||||||
async ngOnInit () {
|
async ngOnInit () {
|
||||||
this.creditsHtml = await this.markdownService.completeMarkdownToHTML(this.markdown)
|
this.creditsHtml = await this.markdownService.unsafeMarkdownToHTML(this.markdown, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,13 @@ import { TableModule } from 'primeng/table'
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
|
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
|
||||||
import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit'
|
import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit'
|
||||||
|
import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module'
|
||||||
|
import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
|
||||||
import { SharedFormModule } from '@app/shared/shared-forms'
|
import { SharedFormModule } from '@app/shared/shared-forms'
|
||||||
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
|
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
|
||||||
import { SharedMainModule } from '@app/shared/shared-main'
|
import { SharedMainModule } from '@app/shared/shared-main'
|
||||||
import { SharedModerationModule } from '@app/shared/shared-moderation'
|
import { SharedModerationModule } from '@app/shared/shared-moderation'
|
||||||
import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
|
import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
|
||||||
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
|
|
||||||
import { AdminRoutingModule } from './admin-routing.module'
|
import { AdminRoutingModule } from './admin-routing.module'
|
||||||
import { AdminComponent } from './admin.component'
|
import { AdminComponent } from './admin.component'
|
||||||
import {
|
import {
|
||||||
|
@ -18,6 +19,7 @@ import {
|
||||||
EditBasicConfigurationComponent,
|
EditBasicConfigurationComponent,
|
||||||
EditConfigurationService,
|
EditConfigurationService,
|
||||||
EditCustomConfigComponent,
|
EditCustomConfigComponent,
|
||||||
|
EditHomepageComponent,
|
||||||
EditInstanceInformationComponent,
|
EditInstanceInformationComponent,
|
||||||
EditLiveConfigurationComponent,
|
EditLiveConfigurationComponent,
|
||||||
EditVODTranscodingComponent
|
EditVODTranscodingComponent
|
||||||
|
@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
|
||||||
SharedVideoCommentModule,
|
SharedVideoCommentModule,
|
||||||
SharedActorImageModule,
|
SharedActorImageModule,
|
||||||
SharedActorImageEditModule,
|
SharedActorImageEditModule,
|
||||||
|
SharedCustomMarkupModule,
|
||||||
|
|
||||||
TableModule,
|
TableModule,
|
||||||
SelectButtonModule,
|
SelectButtonModule,
|
||||||
|
@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
|
||||||
EditVODTranscodingComponent,
|
EditVODTranscodingComponent,
|
||||||
EditLiveConfigurationComponent,
|
EditLiveConfigurationComponent,
|
||||||
EditAdvancedConfigurationComponent,
|
EditAdvancedConfigurationComponent,
|
||||||
EditInstanceInformationComponent
|
EditInstanceInformationComponent,
|
||||||
|
EditHomepageComponent
|
||||||
],
|
],
|
||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
|
|
|
@ -26,22 +26,13 @@
|
||||||
<div class="form-group" formGroupName="instance">
|
<div class="form-group" formGroupName="instance">
|
||||||
<label i18n for="instanceDefaultClientRoute">Landing page</label>
|
<label i18n for="instanceDefaultClientRoute">Landing page</label>
|
||||||
|
|
||||||
<div class="peertube-select-container">
|
<my-select-custom-value
|
||||||
<select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control">
|
id="instanceDefaultClientRoute"
|
||||||
<option i18n value="/videos/overview">Discover videos</option>
|
[items]="defaultLandingPageOptions"
|
||||||
|
formControlName="defaultClientRoute"
|
||||||
<optgroup i18n-label label="Trending pages">
|
inputType="text"
|
||||||
<option i18n value="/videos/trending">Default trending page</option>
|
[clearable]="false"
|
||||||
<option i18n value="/videos/trending?alg=best" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('best')">Best videos</option>
|
></my-select-custom-value>
|
||||||
<option i18n value="/videos/trending?alg=hot" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('hot')">Hot videos</option>
|
|
||||||
<option i18n value="/videos/trending?alg=most-viewed" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-viewed')">Most viewed videos</option>
|
|
||||||
<option i18n value="/videos/trending?alg=most-liked" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-liked')">Most liked videos</option>
|
|
||||||
</optgroup>
|
|
||||||
|
|
||||||
<option i18n value="/videos/recently-added">Recently added videos</option>
|
|
||||||
<option i18n value="/videos/local">Local videos</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
|
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
|
||||||
import { pairwise } from 'rxjs/operators'
|
import { pairwise } from 'rxjs/operators'
|
||||||
import { Component, Input, OnInit } from '@angular/core'
|
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||||
|
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
|
||||||
import { FormGroup } from '@angular/forms'
|
import { FormGroup } from '@angular/forms'
|
||||||
|
import { MenuService } from '@app/core'
|
||||||
import { ServerConfig } from '@shared/models'
|
import { ServerConfig } from '@shared/models'
|
||||||
import { ConfigService } from '../shared/config.service'
|
import { ConfigService } from '../shared/config.service'
|
||||||
|
|
||||||
|
@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service'
|
||||||
templateUrl: './edit-basic-configuration.component.html',
|
templateUrl: './edit-basic-configuration.component.html',
|
||||||
styleUrls: [ './edit-custom-config.component.scss' ]
|
styleUrls: [ './edit-custom-config.component.scss' ]
|
||||||
})
|
})
|
||||||
export class EditBasicConfigurationComponent implements OnInit {
|
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
|
||||||
@Input() form: FormGroup
|
@Input() form: FormGroup
|
||||||
@Input() formErrors: any
|
@Input() formErrors: any
|
||||||
|
|
||||||
@Input() serverConfig: ServerConfig
|
@Input() serverConfig: ServerConfig
|
||||||
|
|
||||||
signupAlertMessage: string
|
signupAlertMessage: string
|
||||||
|
defaultLandingPageOptions: SelectOptionsItem[] = []
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private configService: ConfigService
|
private configService: ConfigService,
|
||||||
|
private menuService: MenuService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
|
this.buildLandingPageOptions()
|
||||||
this.checkSignupField()
|
this.checkSignupField()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnChanges (changes: SimpleChanges) {
|
||||||
|
if (changes['serverConfig']) {
|
||||||
|
this.buildLandingPageOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getVideoQuotaOptions () {
|
getVideoQuotaOptions () {
|
||||||
return this.configService.videoQuotaOptions
|
return this.configService.videoQuotaOptions
|
||||||
}
|
}
|
||||||
|
@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit {
|
||||||
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
|
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildLandingPageOptions () {
|
||||||
|
this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig)
|
||||||
|
.map(o => ({
|
||||||
|
id: o.path,
|
||||||
|
label: o.label,
|
||||||
|
description: o.path
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
private checkSignupField () {
|
private checkSignupField () {
|
||||||
const signupControl = this.form.get('signup.enabled')
|
const signupControl = this.form.get('signup.enabled')
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,16 @@
|
||||||
|
|
||||||
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
|
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
|
||||||
|
|
||||||
|
<ng-container ngbNavItem="instance-homepage">
|
||||||
|
<a ngbNavLink i18n>Homepage</a>
|
||||||
|
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container ngbNavItem="instance-information">
|
<ng-container ngbNavItem="instance-information">
|
||||||
<a ngbNavLink i18n>Instance information</a>
|
<a ngbNavLink i18n>Information</a>
|
||||||
|
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
|
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
|
||||||
|
@ -13,7 +21,7 @@
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container ngbNavItem="basic-configuration">
|
<ng-container ngbNavItem="basic-configuration">
|
||||||
<a ngbNavLink i18n>Basic configuration</a>
|
<a ngbNavLink i18n>Basic</a>
|
||||||
|
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
|
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
|
||||||
|
@ -40,7 +48,7 @@
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container ngbNavItem="advanced-configuration">
|
<ng-container ngbNavItem="advanced-configuration">
|
||||||
<a ngbNavLink i18n>Advanced configuration</a>
|
<a ngbNavLink i18n>Advanced</a>
|
||||||
|
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
|
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
import omit from 'lodash-es/omit'
|
||||||
import { forkJoin } from 'rxjs'
|
import { forkJoin } from 'rxjs'
|
||||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||||
import { Component, OnInit } from '@angular/core'
|
import { Component, OnInit } from '@angular/core'
|
||||||
|
@ -24,9 +25,14 @@ import {
|
||||||
} from '@app/shared/form-validators/custom-config-validators'
|
} from '@app/shared/form-validators/custom-config-validators'
|
||||||
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
|
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
|
||||||
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
|
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
|
||||||
import { CustomConfig, ServerConfig } from '@shared/models'
|
import { CustomPageService } from '@app/shared/shared-main/custom-page'
|
||||||
|
import { CustomConfig, CustomPage, ServerConfig } from '@shared/models'
|
||||||
import { EditConfigurationService } from './edit-configuration.service'
|
import { EditConfigurationService } from './edit-configuration.service'
|
||||||
|
|
||||||
|
type ComponentCustomConfig = CustomConfig & {
|
||||||
|
instanceCustomHomepage: CustomPage
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-edit-custom-config',
|
selector: 'my-edit-custom-config',
|
||||||
templateUrl: './edit-custom-config.component.html',
|
templateUrl: './edit-custom-config.component.html',
|
||||||
|
@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service'
|
||||||
export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
activeNav: string
|
activeNav: string
|
||||||
|
|
||||||
customConfig: CustomConfig
|
customConfig: ComponentCustomConfig
|
||||||
serverConfig: ServerConfig
|
serverConfig: ServerConfig
|
||||||
|
|
||||||
|
homepage: CustomPage
|
||||||
|
|
||||||
languageItems: SelectOptionsItem[] = []
|
languageItems: SelectOptionsItem[] = []
|
||||||
categoryItems: SelectOptionsItem[] = []
|
categoryItems: SelectOptionsItem[] = []
|
||||||
|
|
||||||
|
@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
protected formValidatorService: FormValidatorService,
|
protected formValidatorService: FormValidatorService,
|
||||||
private notifier: Notifier,
|
private notifier: Notifier,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private customPage: CustomPageService,
|
||||||
private serverService: ServerService,
|
private serverService: ServerService,
|
||||||
private editConfigurationService: EditConfigurationService
|
private editConfigurationService: EditConfigurationService
|
||||||
) {
|
) {
|
||||||
|
@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.serverConfig = this.serverService.getTmpConfig()
|
this.serverConfig = this.serverService.getTmpConfig()
|
||||||
this.serverService.getConfig()
|
this.serverService.getConfig()
|
||||||
.subscribe(config => {
|
.subscribe(config => this.serverConfig = config)
|
||||||
this.serverConfig = config
|
|
||||||
})
|
|
||||||
|
|
||||||
const formGroupData: { [key in keyof CustomConfig ]: any } = {
|
const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
|
||||||
instance: {
|
instance: {
|
||||||
name: INSTANCE_NAME_VALIDATOR,
|
name: INSTANCE_NAME_VALIDATOR,
|
||||||
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
|
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
|
||||||
|
@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
disableLocalSearch: null,
|
disableLocalSearch: null,
|
||||||
isDefaultSearch: null
|
isDefaultSearch: null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
instanceCustomHomepage: {
|
||||||
|
content: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
async formValidated () {
|
async formValidated () {
|
||||||
const value: CustomConfig = this.form.getRawValue()
|
const value: ComponentCustomConfig = this.form.getRawValue()
|
||||||
|
|
||||||
this.configService.updateCustomConfig(value)
|
forkJoin([
|
||||||
|
this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
|
||||||
|
this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
|
||||||
|
])
|
||||||
.subscribe(
|
.subscribe(
|
||||||
res => {
|
([ resConfig ]) => {
|
||||||
this.customConfig = res
|
const instanceCustomHomepage = {
|
||||||
|
content: value.instanceCustomHomepage.content
|
||||||
|
}
|
||||||
|
|
||||||
|
this.customConfig = { ...resConfig, instanceCustomHomepage }
|
||||||
|
|
||||||
// Reload general configuration
|
// Reload general configuration
|
||||||
this.serverService.resetConfig()
|
this.serverService.resetConfig()
|
||||||
|
.subscribe(config => this.serverConfig = config)
|
||||||
|
|
||||||
this.updateForm()
|
this.updateForm()
|
||||||
|
|
||||||
|
@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadConfigAndUpdateForm () {
|
private loadConfigAndUpdateForm () {
|
||||||
this.configService.getCustomConfig()
|
forkJoin([
|
||||||
.subscribe(config => {
|
this.configService.getCustomConfig(),
|
||||||
this.customConfig = config
|
this.customPage.getInstanceHomepage()
|
||||||
|
])
|
||||||
|
.subscribe(([ config, homepage ]) => {
|
||||||
|
this.customConfig = { ...config, instanceCustomHomepage: homepage }
|
||||||
|
|
||||||
this.updateForm()
|
this.updateForm()
|
||||||
// Force form validation
|
// Force form validation
|
||||||
|
|
|
@ -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-basic-configuration.component'
|
||||||
export * from './edit-configuration.service'
|
export * from './edit-configuration.service'
|
||||||
export * from './edit-custom-config.component'
|
export * from './edit-custom-config.component'
|
||||||
|
export * from './edit-homepage.component'
|
||||||
export * from './edit-instance-information.component'
|
export * from './edit-instance-information.component'
|
||||||
export * from './edit-live-configuration.component'
|
export * from './edit-live-configuration.component'
|
||||||
export * from './edit-vod-transcoding.component'
|
export * from './edit-vod-transcoding.component'
|
||||||
|
|
|
@ -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
|
// Before HTML rendering restore line feed for markdown list compatibility
|
||||||
const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
|
const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
|
||||||
const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
|
const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
|
||||||
this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
|
this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html)
|
||||||
this.newParentComments = this.parentComments.concat([ this.comment ])
|
this.newParentComments = this.parentComments.concat([ this.comment ])
|
||||||
|
|
||||||
if (this.comment.account) {
|
if (this.comment.account) {
|
||||||
|
|
|
@ -509,7 +509,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
private async setVideoDescriptionHTML () {
|
private async setVideoDescriptionHTML () {
|
||||||
const html = await this.markdownService.textMarkdownToHTML(this.video.description)
|
const html = await this.markdownService.textMarkdownToHTML(this.video.description)
|
||||||
this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
|
this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
private setVideoLikesBarTooltipText () {
|
private setVideoLikesBarTooltipText () {
|
||||||
|
|
|
@ -13,6 +13,10 @@ const routes: Routes = [
|
||||||
canDeactivate: [ MenuGuards.open() ],
|
canDeactivate: [ MenuGuards.open() ],
|
||||||
loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
|
loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
loadChildren: () => import('./+home/home.module').then(m => m.HomeModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'my-account',
|
path: 'my-account',
|
||||||
loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule)
|
loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule)
|
||||||
|
|
|
@ -231,7 +231,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.broadcastMessage = {
|
this.broadcastMessage = {
|
||||||
message: await this.markdownService.completeMarkdownToHTML(messageConfig.message),
|
message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true),
|
||||||
dismissable: messageConfig.dismissable,
|
dismissable: messageConfig.dismissable,
|
||||||
class: classes[messageConfig.level]
|
class: classes[messageConfig.level]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,19 @@
|
||||||
import { fromEvent } from 'rxjs'
|
import { fromEvent } from 'rxjs'
|
||||||
import { debounceTime } from 'rxjs/operators'
|
import { debounceTime } from 'rxjs/operators'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
|
import { GlobalIconName } from '@app/shared/shared-icons'
|
||||||
|
import { sortObjectComparator } from '@shared/core-utils/miscs/miscs'
|
||||||
|
import { ServerConfig } from '@shared/models/server'
|
||||||
import { ScreenService } from '../wrappers'
|
import { ScreenService } from '../wrappers'
|
||||||
|
|
||||||
|
export type MenuLink = {
|
||||||
|
icon: GlobalIconName
|
||||||
|
label: string
|
||||||
|
menuLabel: string
|
||||||
|
path: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MenuService {
|
export class MenuService {
|
||||||
isMenuDisplayed = true
|
isMenuDisplayed = true
|
||||||
|
@ -48,6 +59,53 @@ export class MenuService {
|
||||||
this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
|
this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildCommonLinks (config: ServerConfig) {
|
||||||
|
let entries: MenuLink[] = [
|
||||||
|
{
|
||||||
|
icon: 'globe' as 'globe',
|
||||||
|
label: $localize`Discover videos`,
|
||||||
|
menuLabel: $localize`Discover`,
|
||||||
|
path: '/videos/overview',
|
||||||
|
priority: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'trending' as 'trending',
|
||||||
|
label: $localize`Trending videos`,
|
||||||
|
menuLabel: $localize`Trending`,
|
||||||
|
path: '/videos/trending',
|
||||||
|
priority: 140
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'recently-added' as 'recently-added',
|
||||||
|
label: $localize`Recently added videos`,
|
||||||
|
menuLabel: $localize`Recently added`,
|
||||||
|
path: '/videos/recently-added',
|
||||||
|
priority: 130
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'octagon' as 'octagon',
|
||||||
|
label: $localize`Local videos`,
|
||||||
|
menuLabel: $localize`Local videos`,
|
||||||
|
path: '/videos/local',
|
||||||
|
priority: 120
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (config.homepage.enabled) {
|
||||||
|
entries.push({
|
||||||
|
icon: 'home' as 'home',
|
||||||
|
label: $localize`Home`,
|
||||||
|
menuLabel: $localize`Home`,
|
||||||
|
path: '/home',
|
||||||
|
priority: 160
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = entries.sort(sortObjectComparator('priority', 'desc'))
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
private handleWindowResize () {
|
private handleWindowResize () {
|
||||||
// On touch screens, do not handle window resize event since opened menu is handled with a content overlay
|
// On touch screens, do not handle window resize event since opened menu is handled with a content overlay
|
||||||
if (this.screenService.isInTouchScreen()) return
|
if (this.screenService.isInTouchScreen()) return
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { LinkifierService } from './linkifier.service'
|
import { LinkifierService } from './linkifier.service'
|
||||||
import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html'
|
import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HtmlRendererService {
|
export class HtmlRendererService {
|
||||||
|
@ -20,7 +20,7 @@ export class HtmlRendererService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async toSafeHtml (text: string) {
|
async toSafeHtml (text: string, additionalAllowedTags: string[] = []) {
|
||||||
const [ html ] = await Promise.all([
|
const [ html ] = await Promise.all([
|
||||||
// Convert possible markdown to html
|
// Convert possible markdown to html
|
||||||
this.linkifier.linkify(text),
|
this.linkifier.linkify(text),
|
||||||
|
@ -28,7 +28,11 @@ export class HtmlRendererService {
|
||||||
this.loadSanitizeHtml()
|
this.loadSanitizeHtml()
|
||||||
])
|
])
|
||||||
|
|
||||||
return this.sanitizeHtml(html, SANITIZE_OPTIONS)
|
const options = additionalAllowedTags.length !== 0
|
||||||
|
? getCustomMarkupSanitizeOptions(additionalAllowedTags)
|
||||||
|
: getSanitizeOptions()
|
||||||
|
|
||||||
|
return this.sanitizeHtml(html, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSanitizeHtml () {
|
private async loadSanitizeHtml () {
|
||||||
|
|
|
@ -17,12 +17,15 @@ type MarkdownParsers = {
|
||||||
enhancedMarkdownIt: MarkdownIt
|
enhancedMarkdownIt: MarkdownIt
|
||||||
enhancedWithHTMLMarkdownIt: MarkdownIt
|
enhancedWithHTMLMarkdownIt: MarkdownIt
|
||||||
|
|
||||||
completeMarkdownIt: MarkdownIt
|
unsafeMarkdownIt: MarkdownIt
|
||||||
|
|
||||||
|
customPageMarkdownIt: MarkdownIt
|
||||||
}
|
}
|
||||||
|
|
||||||
type MarkdownConfig = {
|
type MarkdownConfig = {
|
||||||
rules: string[]
|
rules: string[]
|
||||||
html: boolean
|
html: boolean
|
||||||
|
breaks: boolean
|
||||||
escape?: boolean
|
escape?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,18 +38,24 @@ export class MarkdownService {
|
||||||
private markdownParsers: MarkdownParsers = {
|
private markdownParsers: MarkdownParsers = {
|
||||||
textMarkdownIt: null,
|
textMarkdownIt: null,
|
||||||
textWithHTMLMarkdownIt: null,
|
textWithHTMLMarkdownIt: null,
|
||||||
|
|
||||||
enhancedMarkdownIt: null,
|
enhancedMarkdownIt: null,
|
||||||
enhancedWithHTMLMarkdownIt: null,
|
enhancedWithHTMLMarkdownIt: null,
|
||||||
completeMarkdownIt: null
|
|
||||||
|
unsafeMarkdownIt: null,
|
||||||
|
|
||||||
|
customPageMarkdownIt: null
|
||||||
}
|
}
|
||||||
private parsersConfig: MarkdownParserConfigs = {
|
private parsersConfig: MarkdownParserConfigs = {
|
||||||
textMarkdownIt: { rules: TEXT_RULES, html: false },
|
textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false },
|
||||||
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true },
|
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true },
|
||||||
|
|
||||||
enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false },
|
enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false },
|
||||||
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true },
|
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, breaks: true, html: true, escape: true },
|
||||||
|
|
||||||
completeMarkdownIt: { rules: COMPLETE_RULES, html: true }
|
unsafeMarkdownIt: { rules: COMPLETE_RULES, breaks: true, html: true, escape: false },
|
||||||
|
|
||||||
|
customPageMarkdownIt: { rules: COMPLETE_RULES, breaks: false, html: true, escape: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
private emojiModule: any
|
private emojiModule: any
|
||||||
|
@ -54,22 +63,26 @@ export class MarkdownService {
|
||||||
constructor (private htmlRenderer: HtmlRendererService) {}
|
constructor (private htmlRenderer: HtmlRendererService) {}
|
||||||
|
|
||||||
textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
|
textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
|
||||||
if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown, withEmoji)
|
if (withHtml) return this.render({ name: 'textWithHTMLMarkdownIt', markdown, withEmoji })
|
||||||
|
|
||||||
return this.render('textMarkdownIt', markdown, withEmoji)
|
return this.render({ name: 'textMarkdownIt', markdown, withEmoji })
|
||||||
}
|
}
|
||||||
|
|
||||||
enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
|
enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
|
||||||
if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown, withEmoji)
|
if (withHtml) return this.render({ name: 'enhancedWithHTMLMarkdownIt', markdown, withEmoji })
|
||||||
|
|
||||||
return this.render('enhancedMarkdownIt', markdown, withEmoji)
|
return this.render({ name: 'enhancedMarkdownIt', markdown, withEmoji })
|
||||||
}
|
}
|
||||||
|
|
||||||
completeMarkdownToHTML (markdown: string) {
|
unsafeMarkdownToHTML (markdown: string, _trustedInput: true) {
|
||||||
return this.render('completeMarkdownIt', markdown, true)
|
return this.render({ name: 'unsafeMarkdownIt', markdown, withEmoji: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async processVideoTimestamps (html: string) {
|
customPageMarkdownToHTML (markdown: string, additionalAllowedTags: string[]) {
|
||||||
|
return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
|
||||||
|
}
|
||||||
|
|
||||||
|
processVideoTimestamps (html: string) {
|
||||||
return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
|
return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
|
||||||
const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
|
const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
|
||||||
const url = buildVideoLink({ startTime: t })
|
const url = buildVideoLink({ startTime: t })
|
||||||
|
@ -77,7 +90,13 @@ export class MarkdownService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async render (name: keyof MarkdownParsers, markdown: string, withEmoji = false) {
|
private async render (options: {
|
||||||
|
name: keyof MarkdownParsers
|
||||||
|
markdown: string
|
||||||
|
withEmoji: boolean
|
||||||
|
additionalAllowedTags?: string[]
|
||||||
|
}) {
|
||||||
|
const { name, markdown, withEmoji, additionalAllowedTags } = options
|
||||||
if (!markdown) return ''
|
if (!markdown) return ''
|
||||||
|
|
||||||
const config = this.parsersConfig[ name ]
|
const config = this.parsersConfig[ name ]
|
||||||
|
@ -96,7 +115,7 @@ export class MarkdownService {
|
||||||
let html = this.markdownParsers[ name ].render(markdown)
|
let html = this.markdownParsers[ name ].render(markdown)
|
||||||
html = this.avoidTruncatedTags(html)
|
html = this.avoidTruncatedTags(html)
|
||||||
|
|
||||||
if (config.escape) return this.htmlRenderer.toSafeHtml(html)
|
if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags)
|
||||||
|
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
@ -105,7 +124,7 @@ export class MarkdownService {
|
||||||
// FIXME: import('...') returns a struct module, containing a "default" field
|
// FIXME: import('...') returns a struct module, containing a "default" field
|
||||||
const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
|
const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
|
||||||
|
|
||||||
const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
|
const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: config.breaks, html: config.html })
|
||||||
|
|
||||||
for (const rule of config.rules) {
|
for (const rule of config.rules) {
|
||||||
markdownIt.enable(rule)
|
markdownIt.enable(rule)
|
||||||
|
|
|
@ -173,6 +173,9 @@ export class ServerService {
|
||||||
disableLocalSearch: false,
|
disableLocalSearch: false,
|
||||||
isDefaultSearch: false
|
isDefaultSearch: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
homepage: {
|
||||||
|
enabled: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,9 +201,7 @@ export class ServerService {
|
||||||
this.configReset = true
|
this.configReset = true
|
||||||
|
|
||||||
// Notify config update
|
// Notify config update
|
||||||
this.getConfig().subscribe(() => {
|
return this.getConfig()
|
||||||
// empty, to fire a reset config event
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfig () {
|
getConfig () {
|
||||||
|
|
|
@ -123,24 +123,9 @@
|
||||||
<div class="on-instance">
|
<div class="on-instance">
|
||||||
<div i18n class="block-title">ON {{instanceName}}</div>
|
<div i18n class="block-title">ON {{instanceName}}</div>
|
||||||
|
|
||||||
<a class="menu-link" routerLink="/videos/overview" routerLinkActive="active">
|
<a class="menu-link" *ngFor="let commonLink of commonMenuLinks" [routerLink]="commonLink.path" routerLinkActive="active">
|
||||||
<my-global-icon iconName="globe" aria-hidden="true"></my-global-icon>
|
<my-global-icon [iconName]="commonLink.icon" aria-hidden="true"></my-global-icon>
|
||||||
<ng-container i18n>Discover</ng-container>
|
<ng-container>{{ commonLink.menuLabel }}</ng-container>
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="menu-link" routerLink="/videos/trending" routerLinkActive="active">
|
|
||||||
<my-global-icon iconName="trending" aria-hidden="true"></my-global-icon>
|
|
||||||
<ng-container i18n>Trending</ng-container>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="menu-link" routerLink="/videos/recently-added" routerLinkActive="active">
|
|
||||||
<my-global-icon iconName="recently-added" aria-hidden="true"></my-global-icon>
|
|
||||||
<ng-container i18n>Recently added</ng-container>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="menu-link" routerLink="/videos/local" routerLinkActive="active">
|
|
||||||
<my-global-icon iconName="home" aria-hidden="true"></my-global-icon>
|
|
||||||
<ng-container i18n>Local videos</ng-container>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators'
|
||||||
import { ViewportScroller } from '@angular/common'
|
import { ViewportScroller } from '@angular/common'
|
||||||
import { Component, OnInit, ViewChild } from '@angular/core'
|
import { Component, OnInit, ViewChild } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
|
import {
|
||||||
|
AuthService,
|
||||||
|
AuthStatus,
|
||||||
|
AuthUser,
|
||||||
|
MenuLink,
|
||||||
|
MenuService,
|
||||||
|
RedirectService,
|
||||||
|
ScreenService,
|
||||||
|
ServerService,
|
||||||
|
UserService
|
||||||
|
} from '@app/core'
|
||||||
import { scrollToTop } from '@app/helpers'
|
import { scrollToTop } from '@app/helpers'
|
||||||
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
|
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
|
||||||
import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
|
import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
|
||||||
|
@ -35,6 +45,8 @@ export class MenuComponent implements OnInit {
|
||||||
|
|
||||||
currentInterfaceLanguage: string
|
currentInterfaceLanguage: string
|
||||||
|
|
||||||
|
commonMenuLinks: MenuLink[] = []
|
||||||
|
|
||||||
private languages: VideoConstant<string>[] = []
|
private languages: VideoConstant<string>[] = []
|
||||||
private serverConfig: ServerConfig
|
private serverConfig: ServerConfig
|
||||||
private routesPerRight: { [role in UserRight]?: string } = {
|
private routesPerRight: { [role in UserRight]?: string } = {
|
||||||
|
@ -80,7 +92,10 @@ export class MenuComponent implements OnInit {
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.serverConfig = this.serverService.getTmpConfig()
|
this.serverConfig = this.serverService.getTmpConfig()
|
||||||
this.serverService.getConfig()
|
this.serverService.getConfig()
|
||||||
.subscribe(config => this.serverConfig = config)
|
.subscribe(config => {
|
||||||
|
this.serverConfig = config
|
||||||
|
this.buildMenuLinks()
|
||||||
|
})
|
||||||
|
|
||||||
this.isLoggedIn = this.authService.isLoggedIn()
|
this.isLoggedIn = this.authService.isLoggedIn()
|
||||||
if (this.isLoggedIn === true) {
|
if (this.isLoggedIn === true) {
|
||||||
|
@ -241,6 +256,10 @@ export class MenuComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildMenuLinks () {
|
||||||
|
this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig)
|
||||||
|
}
|
||||||
|
|
||||||
private buildUserLanguages () {
|
private buildUserLanguages () {
|
||||||
if (!this.user) {
|
if (!this.user) {
|
||||||
this.videoLanguages = []
|
this.videoLanguages = []
|
||||||
|
|
|
@ -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>
|
<a ngbNavLink i18n>Complete preview</a>
|
||||||
|
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
<div #previewElement></div>
|
||||||
<div [innerHTML]="previewHTML"></div>
|
<div [innerHTML]="previewHTML"></div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { ViewportScroller } from '@angular/common'
|
|
||||||
import truncate from 'lodash-es/truncate'
|
import truncate from 'lodash-es/truncate'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
|
||||||
|
import { ViewportScroller } from '@angular/common'
|
||||||
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
|
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
|
import { SafeHtml } from '@angular/platform-browser'
|
||||||
import { MarkdownService, ScreenService } from '@app/core'
|
import { MarkdownService, ScreenService } from '@app/core'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core'
|
||||||
|
|
||||||
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
|
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
|
||||||
@Input() content = ''
|
@Input() content = ''
|
||||||
|
|
||||||
@Input() classes: string[] | { [klass: string]: any[] | any } = []
|
@Input() classes: string[] | { [klass: string]: any[] | any } = []
|
||||||
|
|
||||||
@Input() textareaMaxWidth = '100%'
|
@Input() textareaMaxWidth = '100%'
|
||||||
@Input() textareaHeight = '150px'
|
@Input() textareaHeight = '150px'
|
||||||
|
|
||||||
@Input() truncate: number
|
@Input() truncate: number
|
||||||
|
|
||||||
@Input() markdownType: 'text' | 'enhanced' = 'text'
|
@Input() markdownType: 'text' | 'enhanced' = 'text'
|
||||||
|
@Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
|
||||||
|
|
||||||
@Input() markdownVideo = false
|
@Input() markdownVideo = false
|
||||||
|
|
||||||
@Input() name = 'description'
|
@Input() name = 'description'
|
||||||
|
|
||||||
@ViewChild('textarea') textareaElement: ElementRef
|
@ViewChild('textarea') textareaElement: ElementRef
|
||||||
|
@ViewChild('previewElement') previewElement: ElementRef
|
||||||
|
|
||||||
|
truncatedPreviewHTML: SafeHtml | string = ''
|
||||||
|
previewHTML: SafeHtml | string = ''
|
||||||
|
|
||||||
truncatedPreviewHTML = ''
|
|
||||||
previewHTML = ''
|
|
||||||
isMaximized = false
|
isMaximized = false
|
||||||
|
|
||||||
maximizeInText = $localize`Maximize editor`
|
maximizeInText = $localize`Maximize editor`
|
||||||
|
@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async markdownRender (text: string) {
|
private async markdownRender (text: string) {
|
||||||
const html = this.markdownType === 'text' ?
|
let html: string
|
||||||
await this.markdownService.textMarkdownToHTML(text) :
|
|
||||||
await this.markdownService.enhancedMarkdownToHTML(text)
|
|
||||||
|
|
||||||
return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html
|
if (this.customMarkdownRenderer) {
|
||||||
|
const result = await this.customMarkdownRenderer(text)
|
||||||
|
|
||||||
|
if (result instanceof HTMLElement) {
|
||||||
|
html = ''
|
||||||
|
|
||||||
|
const wrapperElement = this.previewElement.nativeElement as HTMLElement
|
||||||
|
wrapperElement.innerHTML = ''
|
||||||
|
wrapperElement.appendChild(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html = result
|
||||||
|
} else if (this.markdownType === 'text') {
|
||||||
|
html = await this.markdownService.textMarkdownToHTML(text)
|
||||||
|
} else {
|
||||||
|
html = await this.markdownService.enhancedMarkdownToHTML(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.markdownVideo) {
|
||||||
|
html = this.markdownService.processVideoTimestamps(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,6 +72,7 @@ const icons = {
|
||||||
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
|
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
|
||||||
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
||||||
'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
|
'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
|
||||||
|
'octagon': require('!!raw-loader?!../../../assets/images/feather/octagon.svg').default,
|
||||||
'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default
|
'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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'
|
} from './angular'
|
||||||
import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
|
import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
|
||||||
import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
|
import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
|
||||||
|
import { CustomPageService } from './custom-page'
|
||||||
import { DateToggleComponent } from './date'
|
import { DateToggleComponent } from './date'
|
||||||
import { FeedComponent } from './feeds'
|
import { FeedComponent } from './feeds'
|
||||||
import { LoaderComponent, SmallLoaderComponent } from './loaders'
|
import { LoaderComponent, SmallLoaderComponent } from './loaders'
|
||||||
|
@ -171,7 +172,9 @@ import { VideoChannelService } from './video-channel'
|
||||||
|
|
||||||
VideoCaptionService,
|
VideoCaptionService,
|
||||||
|
|
||||||
VideoChannelService
|
VideoChannelService,
|
||||||
|
|
||||||
|
CustomPageService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class SharedMainModule { }
|
export class SharedMainModule { }
|
||||||
|
|
|
@ -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: {
|
function buildPlaylistLink (options: {
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
|
|
||||||
playlistPosition: number
|
playlistPosition?: number
|
||||||
}) {
|
}) {
|
||||||
const { baseUrl } = options
|
const { baseUrl } = options
|
||||||
|
|
||||||
|
|
|
@ -127,6 +127,7 @@ import { PluginManager } from './server/lib/plugins/plugin-manager'
|
||||||
import { LiveManager } from './server/lib/live-manager'
|
import { LiveManager } from './server/lib/live-manager'
|
||||||
import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes'
|
import { HttpStatusCode } from './shared/core-utils/miscs/http-error-codes'
|
||||||
import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
|
import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
|
||||||
|
import { ServerConfigManager } from '@server/lib/server-config-manager'
|
||||||
|
|
||||||
// ----------- Command line -----------
|
// ----------- Command line -----------
|
||||||
|
|
||||||
|
@ -262,7 +263,8 @@ async function startApplication () {
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
Emailer.Instance.checkConnection(),
|
Emailer.Instance.checkConnection(),
|
||||||
JobQueue.Instance.init()
|
JobQueue.Instance.init(),
|
||||||
|
ServerConfigManager.Instance.init()
|
||||||
])
|
])
|
||||||
|
|
||||||
// Caches initializations
|
// Caches initializations
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { ServerConfigManager } from '@server/lib/server-config-manager'
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import { remove, writeJSON } from 'fs-extra'
|
import { remove, writeJSON } from 'fs-extra'
|
||||||
import { snakeCase } from 'lodash'
|
import { snakeCase } from 'lodash'
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { getServerConfig } from '@server/lib/config'
|
|
||||||
import { UserRight } from '../../../shared'
|
import { UserRight } from '../../../shared'
|
||||||
import { About } from '../../../shared/models/server/about.model'
|
import { About } from '../../../shared/models/server/about.model'
|
||||||
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
|
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
|
||||||
|
@ -43,7 +43,7 @@ configRouter.delete('/custom',
|
||||||
)
|
)
|
||||||
|
|
||||||
async function getConfig (req: express.Request, res: express.Response) {
|
async function getConfig (req: express.Request, res: express.Response) {
|
||||||
const json = await getServerConfig(req.ip)
|
const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
|
||||||
|
|
||||||
return res.json(json)
|
return res.json(json)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { accountsRouter } from './accounts'
|
||||||
import { bulkRouter } from './bulk'
|
import { bulkRouter } from './bulk'
|
||||||
import { configRouter } from './config'
|
import { configRouter } from './config'
|
||||||
|
import { customPageRouter } from './custom-page'
|
||||||
import { jobsRouter } from './jobs'
|
import { jobsRouter } from './jobs'
|
||||||
import { oauthClientsRouter } from './oauth-clients'
|
import { oauthClientsRouter } from './oauth-clients'
|
||||||
import { overviewsRouter } from './overviews'
|
import { overviewsRouter } from './overviews'
|
||||||
|
@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter)
|
||||||
apiRouter.use('/search', searchRouter)
|
apiRouter.use('/search', searchRouter)
|
||||||
apiRouter.use('/overviews', overviewsRouter)
|
apiRouter.use('/overviews', overviewsRouter)
|
||||||
apiRouter.use('/plugins', pluginRouter)
|
apiRouter.use('/plugins', pluginRouter)
|
||||||
|
apiRouter.use('/custom-pages', customPageRouter)
|
||||||
apiRouter.use('/ping', pong)
|
apiRouter.use('/ping', pong)
|
||||||
apiRouter.use('/*', badRequest)
|
apiRouter.use('/*', badRequest)
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { move, readFile } from 'fs-extra'
|
||||||
import * as magnetUtil from 'magnet-uri'
|
import * as magnetUtil from 'magnet-uri'
|
||||||
import * as parseTorrent from 'parse-torrent'
|
import * as parseTorrent from 'parse-torrent'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { getEnabledResolutions } from '@server/lib/config'
|
import { ServerConfigManager } from '@server/lib/server-config-manager'
|
||||||
import { setVideoTags } from '@server/lib/video'
|
import { setVideoTags } from '@server/lib/video'
|
||||||
import { FilteredModelAttributes } from '@server/types'
|
import { FilteredModelAttributes } from '@server/types'
|
||||||
import {
|
import {
|
||||||
|
@ -134,7 +134,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
|
||||||
const targetUrl = body.targetUrl
|
const targetUrl = body.targetUrl
|
||||||
const user = res.locals.oauth.token.User
|
const user = res.locals.oauth.token.User
|
||||||
|
|
||||||
const youtubeDL = new YoutubeDL(targetUrl, getEnabledResolutions('vod'))
|
const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
|
||||||
|
|
||||||
// Get video infos
|
// Get video infos
|
||||||
let youtubeDLInfo: YoutubeDLInfo
|
let youtubeDLInfo: YoutubeDLInfo
|
||||||
|
|
|
@ -2,7 +2,7 @@ import * as cors from 'cors'
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { serveIndexHTML } from '@server/lib/client-html'
|
import { serveIndexHTML } from '@server/lib/client-html'
|
||||||
import { getEnabledResolutions, getRegisteredPlugins, getRegisteredThemes } from '@server/lib/config'
|
import { ServerConfigManager } from '@server/lib/server-config-manager'
|
||||||
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
|
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
|
||||||
import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
|
import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
|
||||||
import { root } from '../helpers/core-utils'
|
import { root } from '../helpers/core-utils'
|
||||||
|
@ -203,10 +203,10 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
registered: getRegisteredPlugins()
|
registered: ServerConfigManager.Instance.getRegisteredPlugins()
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
registered: getRegisteredThemes(),
|
registered: ServerConfigManager.Instance.getRegisteredThemes(),
|
||||||
default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
|
default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
|
@ -222,13 +222,13 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
|
||||||
webtorrent: {
|
webtorrent: {
|
||||||
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
|
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
|
||||||
},
|
},
|
||||||
enabledResolutions: getEnabledResolutions('vod')
|
enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
enabled: CONFIG.LIVE.ENABLED,
|
enabled: CONFIG.LIVE.ENABLED,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
||||||
enabledResolutions: getEnabledResolutions('live')
|
enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
import: {
|
import: {
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { SANITIZE_OPTIONS, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
|
import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
|
||||||
|
|
||||||
|
const sanitizeOptions = getSanitizeOptions()
|
||||||
|
|
||||||
const sanitizeHtml = require('sanitize-html')
|
const sanitizeHtml = require('sanitize-html')
|
||||||
const markdownItEmoji = require('markdown-it-emoji/light')
|
const markdownItEmoji = require('markdown-it-emoji/light')
|
||||||
|
@ -18,7 +20,7 @@ const toSafeHtml = text => {
|
||||||
const html = markdownIt.render(textWithLineFeed)
|
const html = markdownIt.render(textWithLineFeed)
|
||||||
|
|
||||||
// Convert to safe Html
|
// Convert to safe Html
|
||||||
return sanitizeHtml(html, SANITIZE_OPTIONS)
|
return sanitizeHtml(html, sanitizeOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mdToPlainText = text => {
|
const mdToPlainText = text => {
|
||||||
|
@ -28,7 +30,7 @@ const mdToPlainText = text => {
|
||||||
const html = markdownIt.render(text)
|
const html = markdownIt.render(text)
|
||||||
|
|
||||||
// Convert to safe Html
|
// Convert to safe Html
|
||||||
const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS)
|
const safeHtml = sanitizeHtml(html, sanitizeOptions)
|
||||||
|
|
||||||
return safeHtml.replace(/<[^>]+>/g, '')
|
return safeHtml.replace(/<[^>]+>/g, '')
|
||||||
.replace(/\n$/, '')
|
.replace(/\n$/, '')
|
||||||
|
|
|
@ -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 { VideoTagModel } from '../models/video/video-tag'
|
||||||
import { VideoViewModel } from '../models/video/video-view'
|
import { VideoViewModel } from '../models/video/video-view'
|
||||||
import { CONFIG } from './config'
|
import { CONFIG } from './config'
|
||||||
|
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
|
||||||
|
|
||||||
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
|
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
|
||||||
|
|
||||||
|
@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) {
|
||||||
ThumbnailModel,
|
ThumbnailModel,
|
||||||
TrackerModel,
|
TrackerModel,
|
||||||
VideoTrackerModel,
|
VideoTrackerModel,
|
||||||
PluginModel
|
PluginModel,
|
||||||
|
ActorCustomPageModel
|
||||||
])
|
])
|
||||||
|
|
||||||
// Check extensions exist in the database
|
// Check extensions exist in the database
|
||||||
|
|
|
@ -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 { getActivityStreamDuration } from '../models/video/video-format-utils'
|
||||||
import { VideoPlaylistModel } from '../models/video/video-playlist'
|
import { VideoPlaylistModel } from '../models/video/video-playlist'
|
||||||
import { MAccountActor, MChannelActor } from '../types/models'
|
import { MAccountActor, MChannelActor } from '../types/models'
|
||||||
import { getHTMLServerConfig } from './config'
|
import { ServerConfigManager } from './server-config-manager'
|
||||||
|
|
||||||
type Tags = {
|
type Tags = {
|
||||||
ogType: string
|
ogType: string
|
||||||
|
@ -211,7 +211,7 @@ class ClientHtml {
|
||||||
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
|
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
|
||||||
|
|
||||||
const buffer = await readFile(path)
|
const buffer = await readFile(path)
|
||||||
const serverConfig = await getHTMLServerConfig()
|
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
|
||||||
|
|
||||||
let html = buffer.toString()
|
let html = buffer.toString()
|
||||||
html = await ClientHtml.addAsyncPluginCSS(html)
|
html = await ClientHtml.addAsyncPluginCSS(html)
|
||||||
|
@ -280,7 +280,7 @@ class ClientHtml {
|
||||||
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
|
if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
|
||||||
|
|
||||||
const buffer = await readFile(path)
|
const buffer = await readFile(path)
|
||||||
const serverConfig = await getHTMLServerConfig()
|
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
|
||||||
|
|
||||||
let html = buffer.toString()
|
let html = buffer.toString()
|
||||||
|
|
||||||
|
|
|
@ -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 { move, remove, stat } from 'fs-extra'
|
||||||
import { extname } from 'path'
|
import { extname } from 'path'
|
||||||
import { retryTransactionWrapper } from '@server/helpers/database-utils'
|
import { retryTransactionWrapper } from '@server/helpers/database-utils'
|
||||||
|
import { YoutubeDL } from '@server/helpers/youtube-dl'
|
||||||
import { isPostImportVideoAccepted } from '@server/lib/moderation'
|
import { isPostImportVideoAccepted } from '@server/lib/moderation'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks'
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
|
import { ServerConfigManager } from '@server/lib/server-config-manager'
|
||||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||||
import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
|
import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
|
||||||
import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
|
import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
|
||||||
|
@ -33,8 +35,6 @@ import { MThumbnail } from '../../../types/models/video/thumbnail'
|
||||||
import { federateVideoIfNeeded } from '../../activitypub/videos'
|
import { federateVideoIfNeeded } from '../../activitypub/videos'
|
||||||
import { Notifier } from '../../notifier'
|
import { Notifier } from '../../notifier'
|
||||||
import { generateVideoMiniature } from '../../thumbnail'
|
import { generateVideoMiniature } from '../../thumbnail'
|
||||||
import { YoutubeDL } from '@server/helpers/youtube-dl'
|
|
||||||
import { getEnabledResolutions } from '@server/lib/config'
|
|
||||||
|
|
||||||
async function processVideoImport (job: Bull.Job) {
|
async function processVideoImport (job: Bull.Job) {
|
||||||
const payload = job.data as VideoImportPayload
|
const payload = job.data as VideoImportPayload
|
||||||
|
@ -76,7 +76,7 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub
|
||||||
videoImportId: videoImport.id
|
videoImportId: videoImport.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const youtubeDL = new YoutubeDL(videoImport.targetUrl, getEnabledResolutions('vod'))
|
const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
|
||||||
|
|
||||||
return processFile(
|
return processFile(
|
||||||
() => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT),
|
() => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT),
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { MPlugin } from '@server/types/models'
|
||||||
import { PeerTubeHelpers } from '@server/types/plugins'
|
import { PeerTubeHelpers } from '@server/types/plugins'
|
||||||
import { VideoBlacklistCreate } from '@shared/models'
|
import { VideoBlacklistCreate } from '@shared/models'
|
||||||
import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
|
import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
|
||||||
import { getServerConfig } from '../config'
|
import { ServerConfigManager } from '../server-config-manager'
|
||||||
import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
|
import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
|
||||||
import { UserModel } from '@server/models/user/user'
|
import { UserModel } from '@server/models/user/user'
|
||||||
|
|
||||||
|
@ -147,7 +147,7 @@ function buildConfigHelpers () {
|
||||||
},
|
},
|
||||||
|
|
||||||
getServerConfig () {
|
getServerConfig () {
|
||||||
return getServerConfig()
|
return ServerConfigManager.Instance.getServerConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 './blocklist'
|
||||||
import './bulk'
|
import './bulk'
|
||||||
import './config'
|
import './config'
|
||||||
|
import './custom-pages'
|
||||||
import './contact-form'
|
import './contact-form'
|
||||||
import './debug'
|
import './debug'
|
||||||
import './follows'
|
import './follows'
|
||||||
|
|
|
@ -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 './follow-constraints'
|
||||||
import './follows'
|
import './follows'
|
||||||
import './follows-moderation'
|
import './follows-moderation'
|
||||||
|
import './homepage'
|
||||||
import './handle-down'
|
import './handle-down'
|
||||||
import './jobs'
|
import './jobs'
|
||||||
import './logs'
|
import './logs'
|
||||||
|
|
|
@ -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 './account'
|
||||||
|
export * from './actor-custom-page'
|
||||||
export * from './account-blocklist'
|
export * from './account-blocklist'
|
||||||
|
|
|
@ -28,9 +28,24 @@ function isCatchable (value: any) {
|
||||||
return value && typeof value.catch === 'function'
|
return value && typeof value.catch === 'function'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortObjectComparator (key: string, order: 'asc' | 'desc') {
|
||||||
|
return (a: any, b: any) => {
|
||||||
|
if (a[key] < b[key]) {
|
||||||
|
return order === 'asc' ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a[key] > b[key]) {
|
||||||
|
return order === 'asc' ? 1 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
randomInt,
|
randomInt,
|
||||||
compareSemVer,
|
compareSemVer,
|
||||||
isPromise,
|
isPromise,
|
||||||
isCatchable
|
isCatchable,
|
||||||
|
sortObjectComparator
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,45 @@
|
||||||
export const SANITIZE_OPTIONS = {
|
export function getSanitizeOptions () {
|
||||||
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
|
return {
|
||||||
allowedSchemes: [ 'http', 'https' ],
|
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
|
||||||
allowedAttributes: {
|
allowedSchemes: [ 'http', 'https' ],
|
||||||
a: [ 'href', 'class', 'target', 'rel' ]
|
allowedAttributes: {
|
||||||
},
|
'a': [ 'href', 'class', 'target', 'rel' ],
|
||||||
transformTags: {
|
'*': [ 'data-*' ]
|
||||||
a: (tagName: string, attribs: any) => {
|
},
|
||||||
let rel = 'noopener noreferrer'
|
transformTags: {
|
||||||
if (attribs.rel === 'me') rel += ' me'
|
a: (tagName: string, attribs: any) => {
|
||||||
|
let rel = 'noopener noreferrer'
|
||||||
|
if (attribs.rel === 'me') rel += ' me'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tagName,
|
tagName,
|
||||||
attribs: Object.assign(attribs, {
|
attribs: Object.assign(attribs, {
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
rel
|
rel
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) {
|
||||||
|
const base = getSanitizeOptions()
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowedTags: [
|
||||||
|
...base.allowedTags,
|
||||||
|
...additionalAllowedTags,
|
||||||
|
'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||||
|
],
|
||||||
|
allowedSchemes: base.allowedSchemes,
|
||||||
|
allowedAttributes: {
|
||||||
|
...base.allowedAttributes,
|
||||||
|
'*': [ 'data-*', 'style' ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Thanks: https://stackoverflow.com/a/12034334
|
// Thanks: https://stackoverflow.com/a/12034334
|
||||||
export function escapeHTML (stringParam: string) {
|
export function escapeHTML (stringParam: string) {
|
||||||
if (!stringParam) return ''
|
if (!stringParam) return ''
|
||||||
|
|
|
@ -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 './cli/cli'
|
||||||
|
|
||||||
|
export * from './custom-pages/custom-pages'
|
||||||
|
|
||||||
export * from './feeds/feeds'
|
export * from './feeds/feeds'
|
||||||
|
|
||||||
export * from './mock-servers/mock-instances-index'
|
export * from './mock-servers/mock-instances-index'
|
||||||
|
|
|
@ -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.model'
|
||||||
export * from './actor-image.type'
|
export * from './actor-image.type'
|
||||||
export * from './actor.model'
|
export * from './actor.model'
|
||||||
|
export * from './custom-page.model'
|
||||||
export * from './follow.model'
|
export * from './follow.model'
|
||||||
|
|
|
@ -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 './activitypub'
|
||||||
export * from './actors'
|
export * from './actors'
|
||||||
export * from './moderation'
|
export * from './moderation'
|
||||||
|
export * from './custom-markup'
|
||||||
export * from './bulk'
|
export * from './bulk'
|
||||||
export * from './redundancy'
|
export * from './redundancy'
|
||||||
export * from './users'
|
export * from './users'
|
||||||
|
|
|
@ -214,6 +214,10 @@ export interface ServerConfig {
|
||||||
level: BroadcastMessageLevel
|
level: BroadcastMessageLevel
|
||||||
dismissable: boolean
|
dismissable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
homepage: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HTMLServerConfig = Omit<ServerConfig, 'signup'>
|
export type HTMLServerConfig = Omit<ServerConfig, 'signup'>
|
||||||
|
|
|
@ -16,6 +16,7 @@ export const enum UserRight {
|
||||||
MANAGE_JOBS,
|
MANAGE_JOBS,
|
||||||
|
|
||||||
MANAGE_CONFIGURATION,
|
MANAGE_CONFIGURATION,
|
||||||
|
MANAGE_INSTANCE_CUSTOM_PAGE,
|
||||||
|
|
||||||
MANAGE_ACCOUNTS_BLOCKLIST,
|
MANAGE_ACCOUNTS_BLOCKLIST,
|
||||||
MANAGE_SERVERS_BLOCKLIST,
|
MANAGE_SERVERS_BLOCKLIST,
|
||||||
|
|
|
@ -247,6 +247,8 @@ tags:
|
||||||
|
|
||||||
Administrators can also enable the use of a remote search system, indexing
|
Administrators can also enable the use of a remote search system, indexing
|
||||||
videos and channels not could be not federated by the instance.
|
videos and channels not could be not federated by the instance.
|
||||||
|
- name: Homepage
|
||||||
|
description: Get and update the custom homepage
|
||||||
- name: Video Mirroring
|
- name: Video Mirroring
|
||||||
description: |
|
description: |
|
||||||
PeerTube instances can mirror videos from one another, and help distribute some videos.
|
PeerTube instances can mirror videos from one another, and help distribute some videos.
|
||||||
|
@ -281,6 +283,9 @@ x-tagGroups:
|
||||||
- name: Search
|
- name: Search
|
||||||
tags:
|
tags:
|
||||||
- Search
|
- Search
|
||||||
|
- name: Custom pages
|
||||||
|
tags:
|
||||||
|
- Homepage
|
||||||
- name: Moderation
|
- name: Moderation
|
||||||
tags:
|
tags:
|
||||||
- Abuses
|
- Abuses
|
||||||
|
@ -477,6 +482,40 @@ paths:
|
||||||
'200':
|
'200':
|
||||||
description: successful operation
|
description: successful operation
|
||||||
|
|
||||||
|
/custom-pages/homepage/instance:
|
||||||
|
get:
|
||||||
|
summary: Get instance custom homepage
|
||||||
|
tags:
|
||||||
|
- Homepage
|
||||||
|
responses:
|
||||||
|
'404':
|
||||||
|
description: No homepage set
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CustomHomepage'
|
||||||
|
put:
|
||||||
|
summary: Set instance custom homepage
|
||||||
|
tags:
|
||||||
|
- Homepage
|
||||||
|
security:
|
||||||
|
- OAuth2:
|
||||||
|
- admin
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
description: content of the homepage, that will be injected in the client
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: successful operation
|
||||||
|
|
||||||
/jobs/{state}:
|
/jobs/{state}:
|
||||||
get:
|
get:
|
||||||
summary: List instance jobs
|
summary: List instance jobs
|
||||||
|
@ -5740,6 +5779,12 @@ components:
|
||||||
indexUrl:
|
indexUrl:
|
||||||
type: string
|
type: string
|
||||||
format: url
|
format: url
|
||||||
|
homepage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
ServerConfigAbout:
|
ServerConfigAbout:
|
||||||
properties:
|
properties:
|
||||||
instance:
|
instance:
|
||||||
|
@ -5930,6 +5975,12 @@ components:
|
||||||
type: boolean
|
type: boolean
|
||||||
manualApproval:
|
manualApproval:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
|
CustomHomepage:
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
|
||||||
Follow:
|
Follow:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
|
|
Loading…
Reference in New Issue