mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 17:59:37 +02:00
Add ability to customize instance logo
This commit is contained in:
parent
f5fd593976
commit
c0f4de6077
96 changed files with 1910 additions and 532 deletions
|
@ -189,8 +189,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/assets/images",
|
"src/assets/images"
|
||||||
"src/manifest.webmanifest"
|
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/sass/application.scss"
|
"src/sass/application.scss"
|
||||||
|
|
|
@ -25,6 +25,11 @@ export class AdminConfigComponent implements OnInit {
|
||||||
label: $localize`Information`,
|
label: $localize`Information`,
|
||||||
routerLink: 'information'
|
routerLink: 'information'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
label: $localize`Logo`,
|
||||||
|
routerLink: 'logo'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'link',
|
type: 'link',
|
||||||
label: $localize`General`,
|
label: $localize`General`,
|
||||||
|
|
|
@ -15,6 +15,8 @@ import {
|
||||||
} from './pages'
|
} from './pages'
|
||||||
import { AdminConfigCustomizationComponent } from './pages/admin-config-customization.component'
|
import { AdminConfigCustomizationComponent } from './pages/admin-config-customization.component'
|
||||||
import { AdminConfigService } from '../../shared/shared-admin/admin-config.service'
|
import { AdminConfigService } from '../../shared/shared-admin/admin-config.service'
|
||||||
|
import { AdminConfigLogoComponent } from './pages/admin-config-logo.component'
|
||||||
|
import { InstanceLogoService } from './shared/instance-logo.service'
|
||||||
|
|
||||||
export const customConfigResolver: ResolveFn<CustomConfig> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
|
export const customConfigResolver: ResolveFn<CustomConfig> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
|
||||||
return inject(AdminConfigService).getCustomConfig()
|
return inject(AdminConfigService).getCustomConfig()
|
||||||
|
@ -51,6 +53,13 @@ export const commentPoliciesResolver: ResolveFn<VideoConstant<VideoCommentPolicy
|
||||||
return inject(ServerService).getCommentPolicies()
|
return inject(ServerService).getCommentPolicies()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const logosResolver: ResolveFn<ReturnType<InstanceLogoService['getAllLogos']>> = (
|
||||||
|
_route: ActivatedRouteSnapshot,
|
||||||
|
_state: RouterStateSnapshot
|
||||||
|
) => {
|
||||||
|
return inject(InstanceLogoService).getAllLogos()
|
||||||
|
}
|
||||||
|
|
||||||
export const configRoutes: Routes = [
|
export const configRoutes: Routes = [
|
||||||
{
|
{
|
||||||
path: 'config',
|
path: 'config',
|
||||||
|
@ -61,6 +70,9 @@ export const configRoutes: Routes = [
|
||||||
resolve: {
|
resolve: {
|
||||||
customConfig: customConfigResolver
|
customConfig: customConfigResolver
|
||||||
},
|
},
|
||||||
|
providers: [
|
||||||
|
InstanceLogoService
|
||||||
|
],
|
||||||
component: AdminConfigComponent,
|
component: AdminConfigComponent,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
@ -111,6 +123,19 @@ export const configRoutes: Routes = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'logo',
|
||||||
|
component: AdminConfigLogoComponent,
|
||||||
|
canDeactivate: [ CanDeactivateGuard ],
|
||||||
|
resolve: {
|
||||||
|
logos: logosResolver
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
title: $localize`Platform logos`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'general',
|
path: 'general',
|
||||||
component: AdminConfigGeneralComponent,
|
component: AdminConfigGeneralComponent,
|
||||||
|
|
|
@ -38,34 +38,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-col">
|
<div class="content-col">
|
||||||
<div class="form-group">
|
|
||||||
<label i18n for="avatarfile">Square icon</label>
|
|
||||||
|
|
||||||
<div class="form-group-description">
|
|
||||||
<p i18n class="mb-0">Square icon can be used on your custom homepage.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<my-actor-avatar-edit
|
|
||||||
class="d-block mb-4"
|
|
||||||
actorType="account" previewImage="false" [username]="instanceName" displayUsername="false"
|
|
||||||
[avatars]="instanceAvatars" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
|
|
||||||
></my-actor-avatar-edit>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label i18n for="bannerfile">Banner</label>
|
|
||||||
|
|
||||||
<div class="form-group-description">
|
|
||||||
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
|
|
||||||
<p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<my-actor-banner-edit
|
|
||||||
[previewImage]="false" class="d-block mb-4"
|
|
||||||
[bannerUrl]="instanceBannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
|
|
||||||
></my-actor-banner-edit>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label i18n for="instanceName">Name</label>
|
<label i18n for="instanceName">Name</label>
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { HttpErrorResponse } from '@angular/common/http'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core'
|
|
||||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router'
|
import { ActivatedRoute, RouterLink } from '@angular/router'
|
||||||
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
|
import { CanComponentDeactivate, ServerService } from '@app/core'
|
||||||
import { genericUploadErrorHandler } from '@app/helpers'
|
|
||||||
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
|
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
|
||||||
import {
|
import {
|
||||||
ADMIN_EMAIL_VALIDATOR,
|
ADMIN_EMAIL_VALIDATOR,
|
||||||
|
@ -20,12 +18,9 @@ import {
|
||||||
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
|
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||||
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
|
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
|
||||||
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
|
import { ActorImage, CustomConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models'
|
||||||
import { maxBy } from '@peertube/peertube-core-utils'
|
import { Subscription } from 'rxjs'
|
||||||
import { ActorImage, CustomConfig, HTMLServerConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models'
|
|
||||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||||
import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
|
|
||||||
import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component'
|
|
||||||
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
|
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
|
||||||
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
|
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
|
||||||
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
|
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
|
||||||
|
@ -34,7 +29,6 @@ import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/sel
|
||||||
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
|
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
|
||||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
|
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
|
||||||
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
|
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
|
||||||
import { Subscription } from 'rxjs'
|
|
||||||
|
|
||||||
type Form = {
|
type Form = {
|
||||||
admin: FormGroup<{
|
admin: FormGroup<{
|
||||||
|
@ -84,8 +78,6 @@ type Form = {
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
ActorAvatarEditComponent,
|
|
||||||
ActorBannerEditComponent,
|
|
||||||
SelectRadioComponent,
|
SelectRadioComponent,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
CustomMarkupHelpComponent,
|
CustomMarkupHelpComponent,
|
||||||
|
@ -100,8 +92,6 @@ type Form = {
|
||||||
})
|
})
|
||||||
export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
|
export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
|
||||||
private customMarkup = inject(CustomMarkupService)
|
private customMarkup = inject(CustomMarkupService)
|
||||||
private notifier = inject(Notifier)
|
|
||||||
private instanceService = inject(InstanceService)
|
|
||||||
private server = inject(ServerService)
|
private server = inject(ServerService)
|
||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private formReactiveService = inject(FormReactiveService)
|
private formReactiveService = inject(FormReactiveService)
|
||||||
|
@ -136,7 +126,6 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
private serverConfig: HTMLServerConfig
|
|
||||||
private customConfig: CustomConfig
|
private customConfig: CustomConfig
|
||||||
private customConfigSub: Subscription
|
private customConfigSub: Subscription
|
||||||
|
|
||||||
|
@ -155,9 +144,6 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
|
||||||
this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id }))
|
this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id }))
|
||||||
this.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id }))
|
this.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id }))
|
||||||
|
|
||||||
this.serverConfig = this.server.getHTMLConfig()
|
|
||||||
|
|
||||||
this.updateActorImages()
|
|
||||||
this.buildForm()
|
this.buildForm()
|
||||||
|
|
||||||
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
|
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
|
||||||
|
@ -235,72 +221,6 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
|
||||||
return this.customMarkup.getCustomMarkdownRenderer()
|
return this.customMarkup.getCustomMarkdownRenderer()
|
||||||
}
|
}
|
||||||
|
|
||||||
onBannerChange (formData: FormData) {
|
|
||||||
this.instanceService.updateInstanceBanner(formData)
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.notifier.success($localize`Banner changed.`)
|
|
||||||
|
|
||||||
this.resetActorImages()
|
|
||||||
},
|
|
||||||
|
|
||||||
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onBannerDelete () {
|
|
||||||
this.instanceService.deleteInstanceBanner()
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.notifier.success($localize`Banner deleted.`)
|
|
||||||
|
|
||||||
this.resetActorImages()
|
|
||||||
},
|
|
||||||
|
|
||||||
error: err => this.notifier.error(err.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onAvatarChange (formData: FormData) {
|
|
||||||
this.instanceService.updateInstanceAvatar(formData)
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.notifier.success($localize`Avatar changed.`)
|
|
||||||
|
|
||||||
this.resetActorImages()
|
|
||||||
},
|
|
||||||
|
|
||||||
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onAvatarDelete () {
|
|
||||||
this.instanceService.deleteInstanceAvatar()
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.notifier.success($localize`Avatar deleted.`)
|
|
||||||
|
|
||||||
this.resetActorImages()
|
|
||||||
},
|
|
||||||
|
|
||||||
error: err => this.notifier.error(err.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateActorImages () {
|
|
||||||
this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path
|
|
||||||
this.instanceAvatars = this.serverConfig.instance.avatars
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetActorImages () {
|
|
||||||
this.server.resetConfig()
|
|
||||||
.subscribe(config => {
|
|
||||||
this.serverConfig = config
|
|
||||||
|
|
||||||
this.updateActorImages()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
save () {
|
save () {
|
||||||
this.adminConfigService.saveAndUpdateCurrent({
|
this.adminConfigService.saveAndUpdateCurrent({
|
||||||
currentConfig: this.customConfig,
|
currentConfig: this.customConfig,
|
||||||
|
|
|
@ -0,0 +1,129 @@
|
||||||
|
<my-admin-save-bar i18n-title title="Platform information" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
|
||||||
|
|
||||||
|
<form [formGroup]="form">
|
||||||
|
<div class="pt-two-cols mt-4">
|
||||||
|
<div class="title-col">
|
||||||
|
<h2 i18n>LOGO</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="avatarfile">Square icon</label>
|
||||||
|
|
||||||
|
<div class="form-group-description">
|
||||||
|
<p i18n class="mb-0">Square icon is used in the mobile application and can be used on your custom homepage.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-preview-upload class="avatar-preview" formControlName="avatar" inputName="avatar" displayDelete="true"></my-preview-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="bannerfile">Banner</label>
|
||||||
|
|
||||||
|
<div class="form-group-description">
|
||||||
|
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
|
||||||
|
<p i18n>
|
||||||
|
It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances"
|
||||||
|
>JoinPeerTube.org</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-preview-upload class="banner-preview" formControlName="banner" inputName="banner" displayDelete="true"></my-preview-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="favicon">Favicon</label>
|
||||||
|
|
||||||
|
<div class="form-group-description">
|
||||||
|
<p class="mb-0">
|
||||||
|
<ng-container i18n>Favicon is the icon displayed in web browser tab.</ng-container>
|
||||||
|
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p i18n>It will be resized to 32x32 pixels and converted to .png format.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-preview-upload
|
||||||
|
class="favicon-preview"
|
||||||
|
formControlName="favicon"
|
||||||
|
inputName="favicon"
|
||||||
|
displayDelete="true"
|
||||||
|
buttonsAside="true"
|
||||||
|
[previewSize]="{ height: '32px', width: '32px' }"
|
||||||
|
></my-preview-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="header-wide">Desktop header logo</label>
|
||||||
|
|
||||||
|
<div class="form-group-description">
|
||||||
|
<p class="mb-0">
|
||||||
|
<ng-container i18n>Logo displayed in the header on large screens such as desktop computers</ng-container>
|
||||||
|
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p i18n>Its height will be reduced to 48 pixels and the width will be calculated based on the original file ratio.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-preview-upload
|
||||||
|
class="header-wide-preview"
|
||||||
|
formControlName="header-wide"
|
||||||
|
inputName="header-wide"
|
||||||
|
displayDelete="true"
|
||||||
|
buttonsAside="true"
|
||||||
|
[previewSize]="{ height: '48px', width: 'auto' }"
|
||||||
|
></my-preview-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<my-peertube-checkbox
|
||||||
|
inputName="hideInstanceName"
|
||||||
|
formControlName="hideInstanceName"
|
||||||
|
i18n-labelText
|
||||||
|
labelText="Hide the name of your platform in the header on desktop (wide screens)"
|
||||||
|
>
|
||||||
|
<ng-container ngProjectAs="description">
|
||||||
|
<div i18n>Useful for example if your "Desktop header logo" already includes your platform name</div>
|
||||||
|
</ng-container>
|
||||||
|
</my-peertube-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="header-square">Mobile header logo</label>
|
||||||
|
|
||||||
|
<div class="form-group-description">
|
||||||
|
<p class="mb-0">
|
||||||
|
<ng-container i18n>Logo displayed in the header on small screens such as mobile devices</ng-container>
|
||||||
|
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p i18n>It will be resized to 48x48 pixels.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-preview-upload
|
||||||
|
class="header-square-preview"
|
||||||
|
formControlName="header-square"
|
||||||
|
inputName="header-square"
|
||||||
|
displayDelete="true"
|
||||||
|
buttonsAside="true"
|
||||||
|
[previewSize]="{ height: '48px', width: '48px' }"
|
||||||
|
></my-preview-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="opengraph">Social media logo</label>
|
||||||
|
|
||||||
|
<div class="form-group-description">
|
||||||
|
<p class="mb-0">
|
||||||
|
<ng-container i18n>Default logo displayed on social media.</ng-container>
|
||||||
|
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p i18n>It will be resized to 1200x650 pixels.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-preview-upload class="opengraph-preview" formControlName="opengraph" inputName="opengraph" displayDelete="true"></my-preview-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
|
@ -0,0 +1,30 @@
|
||||||
|
@use "sass:math";
|
||||||
|
@use "_variables" as *;
|
||||||
|
@use "_mixins" as *;
|
||||||
|
@use "_form-mixins" as *;
|
||||||
|
|
||||||
|
.banner-preview {
|
||||||
|
max-width: 500px;
|
||||||
|
aspect-ratio: math.div(1, $banner-inverted-ratio);
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview,
|
||||||
|
.header-square-preview {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-wide-preview {
|
||||||
|
width: auto;
|
||||||
|
height: 128px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opengraph-preview {
|
||||||
|
max-width: 500px;
|
||||||
|
aspect-ratio: math.div(1200, 630);
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
|
||||||
|
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { ActivatedRoute } from '@angular/router'
|
||||||
|
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
|
||||||
|
import {
|
||||||
|
BuildFormArgumentTyped,
|
||||||
|
FormReactiveErrorsTyped,
|
||||||
|
FormReactiveMessagesTyped
|
||||||
|
} from '@app/shared/form-validators/form-validator.model'
|
||||||
|
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||||
|
import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
|
||||||
|
import { CustomConfig, LogoType } from '@peertube/peertube-models'
|
||||||
|
import { of, Subscription, switchMap, tap } from 'rxjs'
|
||||||
|
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
|
||||||
|
import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component'
|
||||||
|
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
|
||||||
|
import { InstanceLogoService } from '../shared/instance-logo.service'
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
hideInstanceName: FormControl<boolean>
|
||||||
|
|
||||||
|
avatar: FormControl<Blob>
|
||||||
|
banner: FormControl<Blob>
|
||||||
|
favicon: FormControl<Blob>
|
||||||
|
'header-square': FormControl<Blob>
|
||||||
|
'header-wide': FormControl<Blob>
|
||||||
|
opengraph: FormControl<Blob>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-admin-config-logo',
|
||||||
|
templateUrl: './admin-config-logo.component.html',
|
||||||
|
styleUrls: [ './admin-config-logo.component.scss', './admin-config-common.scss' ],
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
CommonModule,
|
||||||
|
AdminSaveBarComponent,
|
||||||
|
PreviewUploadComponent,
|
||||||
|
PeertubeCheckboxComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AdminConfigLogoComponent implements OnInit, OnDestroy, CanComponentDeactivate {
|
||||||
|
private notifier = inject(Notifier)
|
||||||
|
private logoService = inject(InstanceLogoService)
|
||||||
|
private server = inject(ServerService)
|
||||||
|
private route = inject(ActivatedRoute)
|
||||||
|
private formReactiveService = inject(FormReactiveService)
|
||||||
|
private serverService = inject(ServerService)
|
||||||
|
private adminConfigService = inject(AdminConfigService)
|
||||||
|
|
||||||
|
form: FormGroup<Form>
|
||||||
|
formErrors: FormReactiveErrorsTyped<Form> = {}
|
||||||
|
validationMessages: FormReactiveMessagesTyped<Form> = {}
|
||||||
|
|
||||||
|
private customConfig: CustomConfig
|
||||||
|
private customConfigSub: Subscription
|
||||||
|
|
||||||
|
get instanceName () {
|
||||||
|
return this.server.getHTMLConfig().instance.name
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.customConfig = this.route.parent.snapshot.data['customConfig']
|
||||||
|
|
||||||
|
this.buildForm()
|
||||||
|
|
||||||
|
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
|
||||||
|
.subscribe(customConfig => {
|
||||||
|
this.customConfig = customConfig
|
||||||
|
|
||||||
|
this.form.patchValue({ hideInstanceName: customConfig.client.header.hideInstanceName })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy () {
|
||||||
|
if (this.customConfigSub) this.customConfigSub.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildForm () {
|
||||||
|
const obj: BuildFormArgumentTyped<Form> = {
|
||||||
|
'hideInstanceName': null,
|
||||||
|
|
||||||
|
'avatar': null,
|
||||||
|
'banner': null,
|
||||||
|
'favicon': null,
|
||||||
|
'header-square': null,
|
||||||
|
'header-wide': null,
|
||||||
|
'opengraph': null
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
hideInstanceName: this.customConfig.client.header.hideInstanceName,
|
||||||
|
|
||||||
|
...this.route.snapshot.data.logos
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
formErrors,
|
||||||
|
validationMessages
|
||||||
|
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
|
||||||
|
|
||||||
|
this.form = form
|
||||||
|
this.formErrors = formErrors
|
||||||
|
this.validationMessages = validationMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
canDeactivate () {
|
||||||
|
return { canDeactivate: !this.form.dirty }
|
||||||
|
}
|
||||||
|
|
||||||
|
save () {
|
||||||
|
this.adminConfigService.updateCustomConfig({
|
||||||
|
client: {
|
||||||
|
header: {
|
||||||
|
hideInstanceName: this.form.value.hideInstanceName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
switchMap(() => this.serverService.resetConfig()),
|
||||||
|
tap(newConfig => Object.assign(this.customConfig, newConfig)),
|
||||||
|
switchMap(() => this.buildSaveAvatar()),
|
||||||
|
switchMap(() => this.saveBanner()),
|
||||||
|
switchMap(() => this.saveLogo('favicon')),
|
||||||
|
switchMap(() => this.saveLogo('header-square')),
|
||||||
|
switchMap(() => this.saveLogo('header-wide')),
|
||||||
|
switchMap(() => this.saveLogo('opengraph')),
|
||||||
|
switchMap(() => this.serverService.resetConfig()),
|
||||||
|
switchMap(() => this.logoService.getAllLogos())
|
||||||
|
).subscribe({
|
||||||
|
next: logos => {
|
||||||
|
this.notifier.success($localize`Logos updated`)
|
||||||
|
|
||||||
|
this.form.patchValue(logos)
|
||||||
|
this.form.markAsPristine()
|
||||||
|
},
|
||||||
|
|
||||||
|
error: err => this.notifier.error(err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSaveAvatar () {
|
||||||
|
if (this.form.controls.avatar.pristine) return of(true)
|
||||||
|
|
||||||
|
const avatar = this.form.value.avatar
|
||||||
|
|
||||||
|
return avatar
|
||||||
|
? this.logoService.updateAvatar(avatar)
|
||||||
|
: this.logoService.deleteAvatar()
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveBanner () {
|
||||||
|
if (this.form.controls.banner.pristine) return of(true)
|
||||||
|
|
||||||
|
const banner = this.form.value.banner
|
||||||
|
|
||||||
|
return banner
|
||||||
|
? this.logoService.updateBanner(banner)
|
||||||
|
: this.logoService.deleteBanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveLogo (type: LogoType) {
|
||||||
|
const control = this.form.get(type)
|
||||||
|
if (control.pristine) return of(true)
|
||||||
|
|
||||||
|
const logo = control.value
|
||||||
|
|
||||||
|
return logo
|
||||||
|
? this.logoService.updateLogo(logo, type)
|
||||||
|
: this.logoService.deleteLogo(type)
|
||||||
|
}
|
||||||
|
}
|
120
client/src/app/+admin/config/shared/instance-logo.service.ts
Normal file
120
client/src/app/+admin/config/shared/instance-logo.service.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
|
import { Injectable, inject } from '@angular/core'
|
||||||
|
import { RestExtractor, ServerService } from '@app/core'
|
||||||
|
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
|
||||||
|
import { maxBy } from '@peertube/peertube-core-utils'
|
||||||
|
import { LogoType } from '@peertube/peertube-models'
|
||||||
|
import { logger } from '@root-helpers/logger'
|
||||||
|
import { catchError } from 'rxjs/operators'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstanceLogoService {
|
||||||
|
private authHttp = inject(HttpClient)
|
||||||
|
private restExtractor = inject(RestExtractor)
|
||||||
|
private server = inject(ServerService)
|
||||||
|
|
||||||
|
updateBanner (banner: Blob) {
|
||||||
|
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner/pick'
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('bannerfile', banner)
|
||||||
|
|
||||||
|
return this.authHttp.post(url, formData)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBanner () {
|
||||||
|
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner'
|
||||||
|
|
||||||
|
return this.authHttp.delete(url)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
updateAvatar (avatar: Blob) {
|
||||||
|
const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar/pick'
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('avatarfile', avatar)
|
||||||
|
|
||||||
|
return this.authHttp.post(url, formData)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAvatar () {
|
||||||
|
const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar'
|
||||||
|
|
||||||
|
return this.authHttp.delete(url)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
updateLogo (logo: Blob, type: LogoType) {
|
||||||
|
const url = InstanceService.BASE_CONFIG_URL + '/instance-logo/' + type + '/pick'
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('logofile', logo)
|
||||||
|
|
||||||
|
return this.authHttp.post(url, formData)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLogo (type: LogoType) {
|
||||||
|
const url = InstanceService.BASE_CONFIG_URL + '/instance-logo/' + type
|
||||||
|
|
||||||
|
return this.authHttp.delete(url)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getAllLogos () {
|
||||||
|
const config = this.server.getHTMLConfig()
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = []
|
||||||
|
const result: Partial<Record<LogoType | 'avatar' | 'banner', Blob>> = {}
|
||||||
|
const logoTypes: LogoType[] = [ 'favicon', 'header-square', 'header-wide', 'opengraph' ]
|
||||||
|
|
||||||
|
const fetchLogo = (fileUrl: string, type: keyof typeof result) => {
|
||||||
|
return fetch(fileUrl)
|
||||||
|
.then(response => response.blob())
|
||||||
|
.then(blob => result[type] = blob)
|
||||||
|
.catch(() => {
|
||||||
|
result[type] = null
|
||||||
|
logger.error('Could not fetch logo of type: ' + type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of logoTypes) {
|
||||||
|
const logo = maxBy(config.instance.logo.filter(l => l.type === type), 'width')
|
||||||
|
if (!logo || logo.isFallback === true) {
|
||||||
|
result[type] = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = fetchLogo(logo.fileUrl, type)
|
||||||
|
|
||||||
|
promises.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarFileUrl = maxBy(config.instance.avatars, 'width')?.fileUrl
|
||||||
|
if (!avatarFileUrl) {
|
||||||
|
result.avatar = null
|
||||||
|
} else {
|
||||||
|
promises.push(fetchLogo(avatarFileUrl, 'avatar'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannerFileUrl = maxBy(config.instance.banners, 'width')?.fileUrl
|
||||||
|
if (!bannerFileUrl) {
|
||||||
|
result.banner = null
|
||||||
|
} else {
|
||||||
|
promises.push(fetchLogo(bannerFileUrl, 'banner'))
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,10 +35,7 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="thumbnailfile" i18n>Playlist thumbnail</label>
|
<label for="thumbnailfile" i18n>Playlist thumbnail</label>
|
||||||
|
|
||||||
<my-preview-upload
|
<my-preview-upload i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile"></my-preview-upload>
|
||||||
i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile"
|
|
||||||
previewWidth="223px" previewHeight="122px"
|
|
||||||
></my-preview-upload>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
@use '_form-mixins' as *;
|
@use "_form-mixins" as *;
|
||||||
|
|
||||||
input[type=text] {
|
input[type="text"] {
|
||||||
@include peertube-input-text(340px);
|
@include peertube-input-text(340px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,5 +17,10 @@ my-select-options {
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-col {
|
.content-col {
|
||||||
max-width: 500px;;
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
my-preview-upload {
|
||||||
|
width: 223px;
|
||||||
|
height: 122px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,10 +31,7 @@
|
||||||
The chosen image will be definitive and cannot be modified.
|
The chosen image will be definitive and cannot be modified.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<my-preview-upload
|
<my-preview-upload i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="audioPreviewFile"></my-preview-upload>
|
||||||
i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="audioPreviewFile"
|
|
||||||
previewWidth="360px" previewHeight="200px"
|
|
||||||
></my-preview-upload>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group upload-audio-button">
|
<div class="form-group upload-audio-button">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
|
|
||||||
.first-step-block {
|
.first-step-block {
|
||||||
.form-group-channel {
|
.form-group-channel {
|
||||||
|
@ -13,5 +13,10 @@
|
||||||
|
|
||||||
.audio-preview {
|
.audio-preview {
|
||||||
margin: 30px 0;
|
margin: 30px 0;
|
||||||
|
|
||||||
|
my-preview-upload {
|
||||||
|
width: 360px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,11 @@
|
||||||
<div class="root" [ngClass]="{ 'search-hidden': searchHidden }">
|
<div class="root" [ngClass]="{ 'search-hidden': searchHidden }">
|
||||||
<a class="peertube-title" [routerLink]="getDefaultRoute()" [queryParams]="getDefaultRouteQuery()">
|
<a class="peertube-title" [routerLink]="getDefaultRoute()" [queryParams]="getDefaultRouteQuery()">
|
||||||
|
|
||||||
<span class="icon-logo"></span>
|
<img [src]="getLogoUrl()" alt="" [title]="instanceName" class="logo" />
|
||||||
<span class="instance-name">{{ instanceName }}</span>
|
|
||||||
|
@if (isInstanceNameDisplayed()) {
|
||||||
|
<span class="instance-name">{{ instanceName }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<my-search-typeahead *ngIf="!searchHidden" [hidden]="!isLoaded()"></my-search-typeahead>
|
<my-search-typeahead *ngIf="!searchHidden" [hidden]="!isLoaded()"></my-search-typeahead>
|
||||||
|
|
|
@ -61,15 +61,10 @@
|
||||||
@include margin-left(10px);
|
@include margin-left(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-logo {
|
.logo {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: var(--co-logo-size);
|
|
||||||
height: var(--co-logo-size);
|
|
||||||
min-width: var(--co-logo-size);
|
min-width: var(--co-logo-size);
|
||||||
max-width: var(--co-logo-size);
|
height: var(--co-logo-size);
|
||||||
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: contain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
my-search-typeahead {
|
my-search-typeahead {
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { GlobalIconComponent } from '../shared/shared-icons/global-icon.componen
|
||||||
import { ButtonComponent } from '../shared/shared-main/buttons/button.component'
|
import { ButtonComponent } from '../shared/shared-main/buttons/button.component'
|
||||||
import { SearchTypeaheadComponent } from './search-typeahead.component'
|
import { SearchTypeaheadComponent } from './search-typeahead.component'
|
||||||
import { HeaderService } from './header.service'
|
import { HeaderService } from './header.service'
|
||||||
|
import { findAppropriateImage } from '@peertube/peertube-core-utils'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-header',
|
selector: 'my-header',
|
||||||
|
@ -92,6 +93,10 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
||||||
return this.serverService.getHTMLConfig().instance.name
|
return this.serverService.getHTMLConfig().instance.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isInstanceNameDisplayed () {
|
||||||
|
return this.serverService.getHTMLConfig().client.header.hideInstanceName !== true
|
||||||
|
}
|
||||||
|
|
||||||
isLoaded () {
|
isLoaded () {
|
||||||
return this.config && (!this.loggedIn || !!this.user?.account)
|
return this.config && (!this.loggedIn || !!this.user?.account)
|
||||||
}
|
}
|
||||||
|
@ -104,6 +109,16 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
||||||
return this.screenService.isInSmallView()
|
return this.screenService.isInSmallView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLogoUrl () {
|
||||||
|
const logos = this.serverService.getHTMLConfig().instance.logo
|
||||||
|
|
||||||
|
if (this.isInMobileView()) {
|
||||||
|
return findAppropriateImage(logos.filter(l => l.type === 'header-square'), 36)?.fileUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return findAppropriateImage(logos.filter(l => l.type === 'header-wide'), 36)?.fileUrl
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.htmlConfig = this.serverService.getHTMLConfig()
|
this.htmlConfig = this.serverService.getHTMLConfig()
|
||||||
this.currentInterfaceLanguage = this.languageChooserModal().getCurrentLanguage()
|
this.currentInterfaceLanguage = this.languageChooserModal().getCurrentLanguage()
|
||||||
|
|
|
@ -1,11 +1,27 @@
|
||||||
<div class="root">
|
<div class="preview-container" [ngClass]="{ 'buttons-aside': buttonsAside() }">
|
||||||
<div class="preview-container">
|
@if (imageSrc) {
|
||||||
|
<img [ngStyle]="previewSize()" [src]="imageSrc" class="preview" alt="Preview" i18n-alt />
|
||||||
|
} @else {
|
||||||
|
<div [ngStyle]="previewSize()" class="preview no-image"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
<my-reactive-file
|
<my-reactive-file
|
||||||
[inputName]="inputName()" [inputLabel]="inputLabel()" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
|
#reactiveFile
|
||||||
icon="upload" (fileChanged)="onFileChanged($event)" [buttonTooltip]="getReactiveFileButtonTooltip()" theme="primary"
|
[(ngModel)]="file"
|
||||||
|
[inputName]="inputName()"
|
||||||
|
[inputLabel]="inputLabel()"
|
||||||
|
[extensions]="videoImageExtensions"
|
||||||
|
[maxFileSize]="maxVideoImageSize"
|
||||||
|
placement="right"
|
||||||
|
icon="upload"
|
||||||
|
(fileChanged)="onFileChanged($event)"
|
||||||
|
[buttonTooltip]="getReactiveFileButtonTooltip()"
|
||||||
|
theme="primary"
|
||||||
></my-reactive-file>
|
></my-reactive-file>
|
||||||
|
|
||||||
<img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth(), height: previewHeight() }" [src]="imageSrc" class="preview" alt="Preview" i18n-alt />
|
@if (displayDelete() && file) {
|
||||||
<div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth(), height: previewHeight() }" class="preview no-image"></div>
|
<my-delete-button i18n-title title="Delete the image" (click)="reactiveFile.reset()" theme="danger"></my-delete-button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,29 +1,54 @@
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
|
|
||||||
.root {
|
:host {
|
||||||
height: auto;
|
display: block;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.preview-container {
|
.preview-container,
|
||||||
position: relative;
|
.preview {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
my-reactive-file {
|
.preview-container {
|
||||||
position: absolute;
|
position: relative;
|
||||||
bottom: 10px;
|
|
||||||
left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
.buttons {
|
||||||
object-fit: cover;
|
display: flex;
|
||||||
border-radius: 4px;
|
gap: 10px;
|
||||||
max-width: 100%;
|
}
|
||||||
|
|
||||||
&.no-image {
|
.preview {
|
||||||
border: 2px solid #808080;
|
object-fit: cover;
|
||||||
background-color: pvar(--bg);
|
border-radius: 4px;
|
||||||
}
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container:not(.buttons-aside) {
|
||||||
|
.buttons {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview.no-image {
|
||||||
|
border: 2px solid pvar(--secondary-icon-color);
|
||||||
|
background-color: pvar(--bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container.buttons-aside {
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid pvar(--secondary-icon-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: min-content;
|
||||||
|
|
||||||
|
@include padding(1rem, 1.5rem);
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { Component, forwardRef, OnInit, inject, input } from '@angular/core'
|
import { CommonModule } from '@angular/common'
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { booleanAttribute, Component, forwardRef, inject, input, OnInit } from '@angular/core'
|
||||||
|
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { ServerService } from '@app/core'
|
import { ServerService } from '@app/core'
|
||||||
import { imageToDataURL } from '@root-helpers/images'
|
|
||||||
import { HTMLServerConfig } from '@peertube/peertube-models'
|
import { HTMLServerConfig } from '@peertube/peertube-models'
|
||||||
import { NgIf, NgStyle } from '@angular/common'
|
import { imageToDataURL } from '@root-helpers/images'
|
||||||
import { ReactiveFileComponent } from './reactive-file.component'
|
|
||||||
import { BytesPipe } from '../shared-main/common/bytes.pipe'
|
import { BytesPipe } from '../shared-main/common/bytes.pipe'
|
||||||
|
import { ReactiveFileComponent } from './reactive-file.component'
|
||||||
|
import { DeleteButtonComponent } from '../shared-main/buttons/delete-button.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-preview-upload',
|
selector: 'my-preview-upload',
|
||||||
|
@ -18,23 +19,24 @@ import { BytesPipe } from '../shared-main/common/bytes.pipe'
|
||||||
multi: true
|
multi: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
imports: [ ReactiveFileComponent, NgIf, NgStyle ]
|
imports: [ CommonModule, FormsModule, ReactiveFileComponent, DeleteButtonComponent ]
|
||||||
})
|
})
|
||||||
export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
|
export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
|
||||||
private serverService = inject(ServerService)
|
private serverService = inject(ServerService)
|
||||||
|
|
||||||
|
readonly inputName = input.required<string>()
|
||||||
readonly inputLabel = input<string>(undefined)
|
readonly inputLabel = input<string>(undefined)
|
||||||
readonly inputName = input<string>(undefined)
|
readonly displayDelete = input(false, { transform: booleanAttribute })
|
||||||
readonly previewWidth = input<string>(undefined)
|
readonly buttonsAside = input(false, { transform: booleanAttribute })
|
||||||
readonly previewHeight = input<string>(undefined)
|
readonly previewSize = input<{ width: string, height: string }>(undefined)
|
||||||
|
|
||||||
imageSrc: string
|
imageSrc: string
|
||||||
allowedExtensionsMessage = ''
|
allowedExtensionsMessage = ''
|
||||||
maxSizeText: string
|
maxSizeText: string
|
||||||
|
file: Blob
|
||||||
|
|
||||||
private serverConfig: HTMLServerConfig
|
private serverConfig: HTMLServerConfig
|
||||||
private bytesPipe: BytesPipe
|
private bytesPipe: BytesPipe
|
||||||
private file: Blob
|
|
||||||
|
|
||||||
constructor () {
|
constructor () {
|
||||||
this.bytesPipe = new BytesPipe()
|
this.bytesPipe = new BytesPipe()
|
||||||
|
@ -90,6 +92,8 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
|
||||||
private updatePreview () {
|
private updatePreview () {
|
||||||
if (this.file) {
|
if (this.file) {
|
||||||
imageToDataURL(this.file).then(result => this.imageSrc = result)
|
imageToDataURL(this.file).then(result => this.imageSrc = result)
|
||||||
|
} else {
|
||||||
|
this.imageSrc = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
<div class="filename" *ngIf="displayFilename() === true && filename">{{ filename }}</div>
|
<div class="filename" *ngIf="displayFilename() === true && filename">{{ filename }}</div>
|
||||||
|
|
||||||
<button *ngIf="displayReset() && filename" i18n class="reset-button reset-button-small ms-2" (click)="reset()">
|
<button *ngIf="displayReset() && file" i18n class="reset-button reset-button-small ms-2" (click)="reset()">
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { Component, forwardRef, OnChanges, OnInit, inject, input, output } from '@angular/core'
|
import { CommonModule } from '@angular/common'
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'
|
import { Component, forwardRef, inject, input, OnChanges, OnInit, output } from '@angular/core'
|
||||||
|
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { Notifier } from '@app/core'
|
import { Notifier } from '@app/core'
|
||||||
import { GlobalIconName } from '@app/shared/shared-icons/global-icon.component'
|
import { GlobalIconName } from '@app/shared/shared-icons/global-icon.component'
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgClass, NgIf } from '@angular/common'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-reactive-file',
|
selector: 'my-reactive-file',
|
||||||
|
@ -17,7 +17,7 @@ import { NgClass, NgIf } from '@angular/common'
|
||||||
multi: true
|
multi: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
imports: [ NgClass, NgbTooltip, NgIf, GlobalIconComponent, FormsModule ]
|
imports: [ CommonModule, NgbTooltipModule, GlobalIconComponent, FormsModule ]
|
||||||
})
|
})
|
||||||
export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAccessor {
|
export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAccessor {
|
||||||
private notifier = inject(Notifier)
|
private notifier = inject(Notifier)
|
||||||
|
@ -39,8 +39,7 @@ export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAcc
|
||||||
classes: { [id: string]: boolean } = {}
|
classes: { [id: string]: boolean } = {}
|
||||||
allowedExtensionsMessage = ''
|
allowedExtensionsMessage = ''
|
||||||
fileInputValue: any
|
fileInputValue: any
|
||||||
|
file: File
|
||||||
private file: File
|
|
||||||
|
|
||||||
get filename () {
|
get filename () {
|
||||||
if (!this.file) return ''
|
if (!this.file) return ''
|
||||||
|
@ -62,7 +61,8 @@ export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAcc
|
||||||
this.classes = {
|
this.classes = {
|
||||||
'with-icon': !!this.icon(),
|
'with-icon': !!this.icon(),
|
||||||
'primary-button': this.theme() === 'primary',
|
'primary-button': this.theme() === 'primary',
|
||||||
'secondary-button': this.theme() === 'secondary'
|
'secondary-button': this.theme() === 'secondary',
|
||||||
|
'icon-only': !this.inputLabel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ export abstract class Actor implements ServerActor {
|
||||||
|
|
||||||
const avatar = size && avatarsAscWidth.length > 1
|
const avatar = size && avatarsAscWidth.length > 1
|
||||||
? avatarsAscWidth.find(a => a.width >= size)
|
? avatarsAscWidth.find(a => a.width >= size)
|
||||||
: avatarsAscWidth[avatarsAscWidth.length - 1] // Bigger one
|
: avatarsAscWidth[avatarsAscWidth.length - 1] // Biggest one
|
||||||
|
|
||||||
if (!avatar) return ''
|
if (!avatar) return ''
|
||||||
if (avatar.fileUrl) return avatar.fileUrl
|
if (avatar.fileUrl) return avatar.fileUrl
|
||||||
|
|
|
@ -23,6 +23,8 @@ import { LoaderComponent } from '../common/loader.component'
|
||||||
|
|
||||||
const debugLogger = debug('peertube:button')
|
const debugLogger = debug('peertube:button')
|
||||||
|
|
||||||
|
export type ButtonTheme = 'primary' | 'secondary' | 'tertiary' | 'danger'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-button',
|
selector: 'my-button',
|
||||||
styleUrls: [ './button.component.scss' ],
|
styleUrls: [ './button.component.scss' ],
|
||||||
|
@ -44,7 +46,7 @@ export class ButtonComponent implements OnChanges, AfterViewInit {
|
||||||
private cd = inject(ChangeDetectorRef)
|
private cd = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
readonly label = input('')
|
readonly label = input('')
|
||||||
readonly theme = input<'primary' | 'secondary' | 'tertiary'>('secondary')
|
readonly theme = input<ButtonTheme>('secondary')
|
||||||
readonly icon = input<GlobalIconName>(undefined)
|
readonly icon = input<GlobalIconName>(undefined)
|
||||||
|
|
||||||
readonly href = input<string>(undefined)
|
readonly href = input<string>(undefined)
|
||||||
|
@ -101,6 +103,7 @@ export class ButtonComponent implements OnChanges, AfterViewInit {
|
||||||
'primary-button': this.theme() === 'primary',
|
'primary-button': this.theme() === 'primary',
|
||||||
'secondary-button': this.theme() === 'secondary',
|
'secondary-button': this.theme() === 'secondary',
|
||||||
'tertiary-button': this.theme() === 'tertiary',
|
'tertiary-button': this.theme() === 'tertiary',
|
||||||
|
'danger-button': this.theme() === 'danger',
|
||||||
'has-icon': !!this.icon(),
|
'has-icon': !!this.icon(),
|
||||||
'rounded-icon-button': !!this.rounded(),
|
'rounded-icon-button': !!this.rounded(),
|
||||||
'icon-only': !label,
|
'icon-only': !label,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ChangeDetectionStrategy, Component, OnChanges, input, model } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, OnChanges, input, model } from '@angular/core'
|
||||||
import { ButtonComponent } from './button.component'
|
import { ButtonComponent, ButtonTheme } from './button.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-delete-button',
|
selector: 'my-delete-button',
|
||||||
|
@ -7,7 +7,7 @@ import { ButtonComponent } from './button.component'
|
||||||
<my-button
|
<my-button
|
||||||
icon="delete" theme="secondary"
|
icon="delete" theme="secondary"
|
||||||
[disabled]="disabled()" [label]="label()" [title]="title()"
|
[disabled]="disabled()" [label]="label()" [title]="title()"
|
||||||
[responsiveLabel]="responsiveLabel()"
|
[responsiveLabel]="responsiveLabel()" [theme]="theme()"
|
||||||
></my-button>
|
></my-button>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
@ -18,6 +18,7 @@ export class DeleteButtonComponent implements OnChanges {
|
||||||
readonly title = model<string>(undefined)
|
readonly title = model<string>(undefined)
|
||||||
readonly responsiveLabel = input(false)
|
readonly responsiveLabel = input(false)
|
||||||
readonly disabled = input<boolean>(undefined)
|
readonly disabled = input<boolean>(undefined)
|
||||||
|
readonly theme = input<ButtonTheme>('secondary')
|
||||||
|
|
||||||
ngOnChanges () {
|
ngOnChanges () {
|
||||||
const label = this.label()
|
const label = this.label()
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { forkJoin } from 'rxjs'
|
|
||||||
import { catchError, map } from 'rxjs/operators'
|
|
||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Injectable, inject } from '@angular/core'
|
import { Injectable, inject } from '@angular/core'
|
||||||
import { MarkdownService, RestExtractor, ServerService } from '@app/core'
|
import { MarkdownService, RestExtractor, ServerService } from '@app/core'
|
||||||
import { objectKeysTyped, peertubeTranslate } from '@peertube/peertube-core-utils'
|
import { objectKeysTyped, peertubeTranslate } from '@peertube/peertube-core-utils'
|
||||||
import { About } from '@peertube/peertube-models'
|
import { About } from '@peertube/peertube-models'
|
||||||
import { environment } from '../../../../environments/environment'
|
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
|
import { forkJoin } from 'rxjs'
|
||||||
|
import { catchError, map } from 'rxjs/operators'
|
||||||
|
import { environment } from '../../../../environments/environment'
|
||||||
|
|
||||||
export type AboutHTML = Pick<
|
export type AboutHTML = Pick<
|
||||||
About['instance'],
|
About['instance'],
|
||||||
|
@ -27,8 +27,8 @@ export class InstanceService {
|
||||||
private markdownService = inject(MarkdownService)
|
private markdownService = inject(MarkdownService)
|
||||||
private serverService = inject(ServerService)
|
private serverService = inject(ServerService)
|
||||||
|
|
||||||
private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
|
static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
|
||||||
private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
|
static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
|
||||||
|
|
||||||
getAbout () {
|
getAbout () {
|
||||||
return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
|
return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
|
||||||
|
@ -37,38 +37,6 @@ export class InstanceService {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
updateInstanceBanner (formData: FormData) {
|
|
||||||
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner/pick'
|
|
||||||
|
|
||||||
return this.authHttp.post(url, formData)
|
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteInstanceBanner () {
|
|
||||||
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner'
|
|
||||||
|
|
||||||
return this.authHttp.delete(url)
|
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
updateInstanceAvatar (formData: FormData) {
|
|
||||||
const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar/pick'
|
|
||||||
|
|
||||||
return this.authHttp.post(url, formData)
|
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteInstanceAvatar () {
|
|
||||||
const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar'
|
|
||||||
|
|
||||||
return this.authHttp.delete(url)
|
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) {
|
contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) {
|
||||||
const body = {
|
const body = {
|
||||||
fromEmail,
|
fromEmail,
|
||||||
|
|
|
@ -9,17 +9,6 @@
|
||||||
<!-- Web Manifest file -->
|
<!-- Web Manifest file -->
|
||||||
<link rel="manifest" href="/manifest.webmanifest?[manifestContentHash]">
|
<link rel="manifest" href="/manifest.webmanifest?[manifestContentHash]">
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/client/assets/images/favicon.png?[faviconContentHash]" />
|
|
||||||
<link rel="apple-touch-icon" href="/client/assets/images/icons/icon-144x144.png" sizes="144x144" />
|
|
||||||
<link rel="apple-touch-icon" href="/client/assets/images/icons/icon-192x192.png" sizes="192x192" />
|
|
||||||
|
|
||||||
<!-- logo background-image -->
|
|
||||||
<style type="text/css">
|
|
||||||
.icon-logo {
|
|
||||||
background-image: url(/client/assets/images/logo.svg?[logoContentHash]);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- base url -->
|
<!-- base url -->
|
||||||
<base href="/">
|
<base href="/">
|
||||||
|
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
{
|
|
||||||
"name": "PeerTube",
|
|
||||||
"short_name": "PeerTube",
|
|
||||||
"start_url": "/",
|
|
||||||
"background_color": "#fff",
|
|
||||||
"theme_color": "#fff",
|
|
||||||
"description": "A federated video streaming platform using P2P",
|
|
||||||
"display": "standalone",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/client/assets/images/icons/icon-36x36.png",
|
|
||||||
"sizes": "36x36",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/client/assets/images/icons/icon-48x48.png",
|
|
||||||
"sizes": "48x48",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/client/assets/images/icons/icon-72x72.png",
|
|
||||||
"sizes": "72x72",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/client/assets/images/icons/icon-96x96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/client/assets/images/icons/icon-144x144.png",
|
|
||||||
"sizes": "144x144",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/client/assets/images/icons/icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/client/assets/images/icons/icon-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -8,7 +8,6 @@
|
||||||
"resources": {
|
"resources": {
|
||||||
"files": [
|
"files": [
|
||||||
"/index.html",
|
"/index.html",
|
||||||
"/client/assets/images/icons/favicon.png",
|
|
||||||
"/client/*.css",
|
"/client/*.css",
|
||||||
"/client/*.js",
|
"/client/*.js",
|
||||||
"/manifest.webmanifest"
|
"/manifest.webmanifest"
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
@use 'sass:math';
|
@use "sass:math";
|
||||||
@use 'sass:color';
|
@use "sass:color";
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
|
|
||||||
@mixin secondary-button (
|
@mixin secondary-button(
|
||||||
$fg: inherit,
|
$fg: inherit,
|
||||||
$active-bg: pvar(--bg-secondary-500),
|
$active-bg: pvar(--bg-secondary-500),
|
||||||
$hover-bg: pvar(--bg-secondary-450),
|
$hover-bg: pvar(--bg-secondary-450),
|
||||||
|
@ -48,7 +48,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@mixin primary-button {
|
@mixin primary-button {
|
||||||
@include button-focus(pvar(--primary-350));
|
@include button-focus(pvar(--primary-350));
|
||||||
|
|
||||||
|
@ -148,7 +147,7 @@
|
||||||
@mixin danger-button {
|
@mixin danger-button {
|
||||||
background-color: pvar(--input-danger-bg);
|
background-color: pvar(--input-danger-bg);
|
||||||
color: pvar(--input-danger-fg);
|
color: pvar(--input-danger-fg);
|
||||||
border: 0;
|
border: 1px solid pvar(--input-danger-bg);
|
||||||
|
|
||||||
@include button-focus(pvar(--input-danger-bg));
|
@include button-focus(pvar(--input-danger-bg));
|
||||||
|
|
||||||
|
@ -156,7 +155,7 @@
|
||||||
&:active,
|
&:active,
|
||||||
&.active,
|
&.active,
|
||||||
&:focus:not(:focus-visible) {
|
&:focus:not(:focus-visible) {
|
||||||
opacity: 0.8;
|
border-color: pvar(--input-danger-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[disabled] {
|
&[disabled] {
|
||||||
|
@ -185,7 +184,7 @@
|
||||||
padding: pvar(--input-y-padding) 8px;
|
padding: pvar(--input-y-padding) 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:is(input[type=button]) {
|
&:is(input[type="button"]) {
|
||||||
// Because of primeng that redefines border-radius of all input[type="..."]
|
// Because of primeng that redefines border-radius of all input[type="..."]
|
||||||
border-radius: pvar(--input-border-radius) !important;
|
border-radius: pvar(--input-border-radius) !important;
|
||||||
}
|
}
|
||||||
|
@ -250,7 +249,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
input[type=file] {
|
input[type="file"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
<!-- /!\ The following comment is used by the server to prerender some tags /!\ -->
|
<!-- /!\ The following comment is used by the server to prerender some tags /!\ -->
|
||||||
|
|
||||||
|
<!-- favicon tag -->
|
||||||
<!-- title tag -->
|
<!-- title tag -->
|
||||||
<!-- description tag -->
|
<!-- description tag -->
|
||||||
<!-- custom css tag -->
|
<!-- custom css tag -->
|
||||||
|
@ -14,8 +15,6 @@
|
||||||
<!-- server config -->
|
<!-- server config -->
|
||||||
|
|
||||||
<!-- /!\ Do not remove it /!\ -->
|
<!-- /!\ Do not remove it /!\ -->
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/client/assets/images/favicon.png" />
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="custom-css" class="standalone-video-embed">
|
<body id="custom-css" class="standalone-video-embed">
|
||||||
|
|
|
@ -134,14 +134,15 @@ storage:
|
||||||
cache: 'storage/cache/'
|
cache: 'storage/cache/'
|
||||||
plugins: 'storage/plugins/'
|
plugins: 'storage/plugins/'
|
||||||
well_known: 'storage/well-known/'
|
well_known: 'storage/well-known/'
|
||||||
|
# Various admin/user uploads that are not suitable for the folders above
|
||||||
|
uploads: 'storage/uploads/'
|
||||||
# Overridable client files in client/dist/assets/images:
|
# Overridable client files in client/dist/assets/images:
|
||||||
# - logo.svg
|
# - default-avatar-account-48x48.png
|
||||||
# - favicon.png
|
|
||||||
# - default-playlist.jpg
|
|
||||||
# - default-avatar-account.png
|
# - default-avatar-account.png
|
||||||
|
# - default-avatar-video-channel-48x48.png
|
||||||
# - default-avatar-video-channel.png
|
# - default-avatar-video-channel.png
|
||||||
# - and icons/*.png (PWA)
|
# - default-playlist.jpg
|
||||||
# Could contain for example assets/images/favicon.png
|
# Could contain for example "assets/images/default-playlist.jpg"
|
||||||
# If the file exists, peertube will serve it
|
# If the file exists, peertube will serve it
|
||||||
# If not, peertube will fallback to the default file
|
# If not, peertube will fallback to the default file
|
||||||
client_overrides: 'storage/client-overrides/'
|
client_overrides: 'storage/client-overrides/'
|
||||||
|
@ -797,7 +798,7 @@ import:
|
||||||
# * https://yt-dl.org/downloads/latest/youtube-dl
|
# * https://yt-dl.org/downloads/latest/youtube-dl
|
||||||
#
|
#
|
||||||
# You can also use a youtube-dl standalone binary (requires python_path: null)
|
# You can also use a youtube-dl standalone binary (requires python_path: null)
|
||||||
# GNU/Linux binaries with support for impersonating browser requests (required by some i such as Vimeo) examples:
|
# GNU/Linux binaries with support for impersonating browser requests (required by some platforms such as Vimeo) examples:
|
||||||
# * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux (x64)
|
# * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux (x64)
|
||||||
# * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_armv7l (ARMv7)
|
# * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_armv7l (ARMv7)
|
||||||
# * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_armv7l (ARMv8/AArch64/ARM64)
|
# * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_armv7l (ARMv8/AArch64/ARM64)
|
||||||
|
@ -1060,6 +1061,11 @@ search:
|
||||||
# PeerTube client/interface configuration
|
# PeerTube client/interface configuration
|
||||||
client:
|
client:
|
||||||
|
|
||||||
|
header:
|
||||||
|
# Hide the instance name in the header on desktop
|
||||||
|
# Useful if your logo already contains the instance name
|
||||||
|
hide_instance_name: false
|
||||||
|
|
||||||
videos:
|
videos:
|
||||||
miniature:
|
miniature:
|
||||||
# By default PeerTube client displays author username
|
# By default PeerTube client displays author username
|
||||||
|
|
|
@ -132,14 +132,15 @@ storage:
|
||||||
cache: '/var/www/peertube/storage/cache/'
|
cache: '/var/www/peertube/storage/cache/'
|
||||||
plugins: '/var/www/peertube/storage/plugins/'
|
plugins: '/var/www/peertube/storage/plugins/'
|
||||||
well_known: '/var/www/peertube/storage/well-known/'
|
well_known: '/var/www/peertube/storage/well-known/'
|
||||||
|
# Various admin/user uploads that are not suitable for the folders above
|
||||||
|
uploads: '/var/www/peertube/storage/uploads/'
|
||||||
# Overridable client files in client/dist/assets/images:
|
# Overridable client files in client/dist/assets/images:
|
||||||
# - logo.svg
|
# - default-avatar-account-48x48.png
|
||||||
# - favicon.png
|
|
||||||
# - default-playlist.jpg
|
|
||||||
# - default-avatar-account.png
|
# - default-avatar-account.png
|
||||||
|
# - default-avatar-video-channel-48x48.png
|
||||||
# - default-avatar-video-channel.png
|
# - default-avatar-video-channel.png
|
||||||
# - and icons/*.png (PWA)
|
# - default-playlist.jpg
|
||||||
# Could contain for example assets/images/favicon.png
|
# Could contain for example "assets/images/default-playlist.jpg"
|
||||||
# If the file exists, peertube will serve it
|
# If the file exists, peertube will serve it
|
||||||
# If not, peertube will fallback to the default file
|
# If not, peertube will fallback to the default file
|
||||||
client_overrides: '/var/www/peertube/storage/client-overrides/'
|
client_overrides: '/var/www/peertube/storage/client-overrides/'
|
||||||
|
@ -1070,6 +1071,11 @@ search:
|
||||||
# PeerTube client/interface configuration
|
# PeerTube client/interface configuration
|
||||||
client:
|
client:
|
||||||
|
|
||||||
|
header:
|
||||||
|
# Hide the instance name in the header on desktop
|
||||||
|
# Useful if your logo already contains the instance name
|
||||||
|
hide_instance_name: false
|
||||||
|
|
||||||
videos:
|
videos:
|
||||||
miniature:
|
miniature:
|
||||||
# By default PeerTube client displays author username
|
# By default PeerTube client displays author username
|
||||||
|
@ -1153,3 +1159,8 @@ email:
|
||||||
subject:
|
subject:
|
||||||
# Support {{instanceName}} template variable
|
# Support {{instanceName}} template variable
|
||||||
prefix: '[{{instanceName}}] '
|
prefix: '[{{instanceName}}] '
|
||||||
|
|
||||||
|
video_comments:
|
||||||
|
# Accept or not comments from remote instances
|
||||||
|
# This setting is not retroactive: current remote comments of your instance will not be affected
|
||||||
|
accept_remote_comments: true
|
||||||
|
|
|
@ -26,6 +26,7 @@ storage:
|
||||||
cache: 'test1/cache/'
|
cache: 'test1/cache/'
|
||||||
plugins: 'test1/plugins/'
|
plugins: 'test1/plugins/'
|
||||||
well_known: 'test1/well-known/'
|
well_known: 'test1/well-known/'
|
||||||
|
uploads: 'test1/uploads/'
|
||||||
client_overrides: 'test1/client-overrides/'
|
client_overrides: 'test1/client-overrides/'
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
|
|
@ -26,6 +26,7 @@ storage:
|
||||||
cache: 'test2/cache/'
|
cache: 'test2/cache/'
|
||||||
plugins: 'test2/plugins/'
|
plugins: 'test2/plugins/'
|
||||||
well_known: 'test2/well-known/'
|
well_known: 'test2/well-known/'
|
||||||
|
uploads: 'test2/uploads/'
|
||||||
client_overrides: 'test2/client-overrides/'
|
client_overrides: 'test2/client-overrides/'
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
|
|
@ -26,6 +26,7 @@ storage:
|
||||||
cache: 'test3/cache/'
|
cache: 'test3/cache/'
|
||||||
plugins: 'test3/plugins/'
|
plugins: 'test3/plugins/'
|
||||||
well_known: 'test3/well-known/'
|
well_known: 'test3/well-known/'
|
||||||
|
uploads: 'test3/uploads/'
|
||||||
client_overrides: 'test3/client-overrides/'
|
client_overrides: 'test3/client-overrides/'
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
|
|
@ -26,6 +26,7 @@ storage:
|
||||||
cache: 'test4/cache/'
|
cache: 'test4/cache/'
|
||||||
plugins: 'test4/plugins/'
|
plugins: 'test4/plugins/'
|
||||||
well_known: 'test4/well-known/'
|
well_known: 'test4/well-known/'
|
||||||
|
uploads: 'test4/uploads/'
|
||||||
client_overrides: 'test4/client-overrides/'
|
client_overrides: 'test4/client-overrides/'
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
|
|
@ -26,6 +26,7 @@ storage:
|
||||||
cache: 'test5/cache/'
|
cache: 'test5/cache/'
|
||||||
plugins: 'test5/plugins/'
|
plugins: 'test5/plugins/'
|
||||||
well_known: 'test5/well-known/'
|
well_known: 'test5/well-known/'
|
||||||
|
uploads: 'test5/uploads/'
|
||||||
client_overrides: 'test5/client-overrides/'
|
client_overrides: 'test5/client-overrides/'
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
|
|
@ -26,6 +26,7 @@ storage:
|
||||||
cache: 'test6/cache/'
|
cache: 'test6/cache/'
|
||||||
plugins: 'test6/plugins/'
|
plugins: 'test6/plugins/'
|
||||||
well_known: 'test6/well-known/'
|
well_known: 'test6/well-known/'
|
||||||
|
uploads: 'test6/uploads/'
|
||||||
client_overrides: 'test6/client-overrides/'
|
client_overrides: 'test6/client-overrides/'
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
|
11
packages/core-utils/src/common/image.ts
Normal file
11
packages/core-utils/src/common/image.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export function findAppropriateImage<T extends { width: number, height: number }> (images: T[], wantedWidth: number) {
|
||||||
|
const imagesSorted = images.sort((a, b) => a.width - b.width)
|
||||||
|
|
||||||
|
for (const image of imagesSorted) {
|
||||||
|
if (image.width >= wantedWidth) {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images[images.length - 1] // Biggest one
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './array.js'
|
export * from './array.js'
|
||||||
export * from './random.js'
|
export * from './random.js'
|
||||||
export * from './date.js'
|
export * from './date.js'
|
||||||
|
export * from './image.js'
|
||||||
export * from './number.js'
|
export * from './number.js'
|
||||||
export * from './object.js'
|
export * from './object.js'
|
||||||
export * from './regexp.js'
|
export * from './regexp.js'
|
||||||
|
|
|
@ -19,7 +19,7 @@ export class FFmpegImage {
|
||||||
|
|
||||||
const command = this.commandWrapper.buildCommand(path)
|
const command = this.commandWrapper.buildCommand(path)
|
||||||
|
|
||||||
if (newSize) command.size(`${newSize.width}x${newSize.height}`)
|
if (newSize) command.size(`${newSize.width ?? '?'}x${newSize.height ?? '?'}`)
|
||||||
|
|
||||||
command.output(destination)
|
command.output(destination)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export interface ActorImage {
|
export interface ActorImage {
|
||||||
|
height: number
|
||||||
width: number
|
width: number
|
||||||
|
|
||||||
// TODO: remove, deprecated in 7.1
|
// TODO: remove, deprecated in 7.1
|
||||||
|
|
|
@ -79,6 +79,10 @@ export interface CustomConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
client: {
|
client: {
|
||||||
|
header: {
|
||||||
|
hideInstanceName: boolean
|
||||||
|
}
|
||||||
|
|
||||||
videos: {
|
videos: {
|
||||||
miniature: {
|
miniature: {
|
||||||
preferAuthorDisplayName: boolean
|
preferAuthorDisplayName: boolean
|
||||||
|
|
|
@ -6,7 +6,9 @@ export * from './contact-form.model.js'
|
||||||
export * from './custom-config.model.js'
|
export * from './custom-config.model.js'
|
||||||
export * from './debug.model.js'
|
export * from './debug.model.js'
|
||||||
export * from './emailer.model.js'
|
export * from './emailer.model.js'
|
||||||
|
export * from './upload-image.type.js'
|
||||||
export * from './job.model.js'
|
export * from './job.model.js'
|
||||||
|
export * from './logo-type.type.js'
|
||||||
export * from './peertube-problem-document.model.js'
|
export * from './peertube-problem-document.model.js'
|
||||||
export * from './server-config.model.js'
|
export * from './server-config.model.js'
|
||||||
export * from './server-debug.model.js'
|
export * from './server-debug.model.js'
|
||||||
|
|
1
packages/models/src/server/logo-type.type.ts
Normal file
1
packages/models/src/server/logo-type.type.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type LogoType = 'favicon' | 'header-wide' | 'header-square' | 'opengraph'
|
|
@ -1,4 +1,4 @@
|
||||||
import { ActorImage, VideoCommentPolicyType } from '../index.js'
|
import { ActorImage, LogoType, VideoCommentPolicyType } from '../index.js'
|
||||||
import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js'
|
import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js'
|
||||||
import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
|
import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
|
||||||
import { VideoPrivacyType } from '../videos/video-privacy.enum.js'
|
import { VideoPrivacyType } from '../videos/video-privacy.enum.js'
|
||||||
|
@ -37,6 +37,10 @@ export interface ServerConfig {
|
||||||
serverCommit?: string
|
serverCommit?: string
|
||||||
|
|
||||||
client: {
|
client: {
|
||||||
|
header: {
|
||||||
|
hideInstanceName: boolean
|
||||||
|
}
|
||||||
|
|
||||||
videos: {
|
videos: {
|
||||||
miniature: {
|
miniature: {
|
||||||
preferAuthorDisplayName: boolean
|
preferAuthorDisplayName: boolean
|
||||||
|
@ -132,6 +136,14 @@ export interface ServerConfig {
|
||||||
|
|
||||||
avatars: ActorImage[]
|
avatars: ActorImage[]
|
||||||
banners: ActorImage[]
|
banners: ActorImage[]
|
||||||
|
|
||||||
|
logo: {
|
||||||
|
type: LogoType
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
fileUrl: string
|
||||||
|
isFallback: boolean
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
search: {
|
search: {
|
||||||
|
|
8
packages/models/src/server/upload-image.type.ts
Normal file
8
packages/models/src/server/upload-image.type.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
export const UploadImageType = {
|
||||||
|
INSTANCE_FAVICON: 1,
|
||||||
|
INSTANCE_HEADER_WIDE: 2,
|
||||||
|
INSTANCE_HEADER_SQUARE: 3,
|
||||||
|
INSTANCE_OPENGRAPH: 4
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type UploadImageType_Type = typeof UploadImageType[keyof typeof UploadImageType]
|
|
@ -1,4 +1,4 @@
|
||||||
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models'
|
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, LogoType, ServerConfig } from '@peertube/peertube-models'
|
||||||
import { DeepPartial } from '@peertube/peertube-typescript-utils'
|
import { DeepPartial } from '@peertube/peertube-typescript-utils'
|
||||||
import merge from 'lodash-es/merge.js'
|
import merge from 'lodash-es/merge.js'
|
||||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js'
|
import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js'
|
||||||
|
@ -539,6 +539,45 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
updateInstanceLogo (
|
||||||
|
options: OverrideCommandOptions & {
|
||||||
|
fixture: string
|
||||||
|
type: LogoType
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { fixture, type } = options
|
||||||
|
|
||||||
|
return this.updateImageRequest({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path: '/api/v1/config/instance-logo/' + type + '/pick',
|
||||||
|
fixture,
|
||||||
|
fieldname: 'logofile',
|
||||||
|
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteInstanceLogo (
|
||||||
|
options: OverrideCommandOptions & {
|
||||||
|
type: LogoType
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { type } = options
|
||||||
|
|
||||||
|
return this.deleteRequest({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path: '/api/v1/config/instance-logo/' + type,
|
||||||
|
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
getCustomConfig (options: OverrideCommandOptions = {}) {
|
getCustomConfig (options: OverrideCommandOptions = {}) {
|
||||||
const path = '/api/v1/config/custom'
|
const path = '/api/v1/config/custom'
|
||||||
|
|
||||||
|
|
|
@ -65,7 +65,7 @@ export type RunServerOptions = {
|
||||||
hideLogs?: boolean
|
hideLogs?: boolean
|
||||||
nodeArgs?: string[]
|
nodeArgs?: string[]
|
||||||
peertubeArgs?: string[]
|
peertubeArgs?: string[]
|
||||||
env?: { [ id: string ]: string }
|
env?: { [id: string]: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PeerTubeServer {
|
export class PeerTubeServer {
|
||||||
|
@ -400,6 +400,7 @@ export class PeerTubeServer {
|
||||||
captions: this.getDirectoryPath('captions') + '/',
|
captions: this.getDirectoryPath('captions') + '/',
|
||||||
cache: this.getDirectoryPath('cache') + '/',
|
cache: this.getDirectoryPath('cache') + '/',
|
||||||
plugins: this.getDirectoryPath('plugins') + '/',
|
plugins: this.getDirectoryPath('plugins') + '/',
|
||||||
|
uploads: this.getDirectoryPath('uploads') + '/',
|
||||||
well_known: this.getDirectoryPath('well-known') + '/'
|
well_known: this.getDirectoryPath('well-known') + '/'
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
import { omit } from '@peertube/peertube-core-utils'
|
import { omit } from '@peertube/peertube-core-utils'
|
||||||
import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
|
import { ActorImageType, CustomConfig, HttpStatusCode, LogoType } from '@peertube/peertube-models'
|
||||||
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
|
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
|
@ -215,10 +215,14 @@ describe('Test config API validators', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Updating instance image', function () {
|
describe('Updating instance image/logo', function () {
|
||||||
const toTest = [
|
const toTest = [
|
||||||
{ path: '/api/v1/config/instance-banner/pick', attachName: 'bannerfile' },
|
{ path: '/api/v1/config/instance-banner/pick', attachName: 'bannerfile' },
|
||||||
{ path: '/api/v1/config/instance-avatar/pick', attachName: 'avatarfile' }
|
{ path: '/api/v1/config/instance-avatar/pick', attachName: 'avatarfile' },
|
||||||
|
{ path: '/api/v1/config/instance-logo/favicon/pick', attachName: 'logofile' },
|
||||||
|
{ path: '/api/v1/config/instance-logo/header-square/pick', attachName: 'logofile' },
|
||||||
|
{ path: '/api/v1/config/instance-logo/header-wide/pick', attachName: 'logofile' },
|
||||||
|
{ path: '/api/v1/config/instance-logo/opengraph/pick', attachName: 'logofile' }
|
||||||
]
|
]
|
||||||
|
|
||||||
it('Should fail with an incorrect input file', async function () {
|
it('Should fail with an incorrect input file', async function () {
|
||||||
|
@ -311,6 +315,28 @@ describe('Test config API validators', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Deleting instance logos', function () {
|
||||||
|
const types: LogoType[] = [ 'favicon', 'header-square', 'header-wide', 'opengraph' ]
|
||||||
|
|
||||||
|
it('Should fail without token', async function () {
|
||||||
|
for (const type of types) {
|
||||||
|
await server.config.deleteInstanceLogo({ type, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail without the appropriate rights', async function () {
|
||||||
|
for (const type of types) {
|
||||||
|
await server.config.deleteInstanceLogo({ type, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct params', async function () {
|
||||||
|
for (const type of types) {
|
||||||
|
await server.config.deleteInstanceLogo({ type })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests([ server ])
|
await cleanupTests([ server ])
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { ActorImageType, CustomConfig, HttpStatusCode, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models'
|
import { ActorImageType, CustomConfig, HttpStatusCode, LogoType, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models'
|
||||||
import {
|
import {
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
|
@ -47,6 +47,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
|
||||||
|
|
||||||
expect(data.services.twitter.username).to.equal('@Chocobozzz')
|
expect(data.services.twitter.username).to.equal('@Chocobozzz')
|
||||||
|
|
||||||
|
expect(data.client.header.hideInstanceName).to.be.false
|
||||||
expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.false
|
expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.false
|
||||||
expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.false
|
expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.false
|
||||||
|
|
||||||
|
@ -222,6 +223,9 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
|
header: {
|
||||||
|
hideInstanceName: true
|
||||||
|
},
|
||||||
videos: {
|
videos: {
|
||||||
miniature: {
|
miniature: {
|
||||||
preferAuthorDisplayName: true
|
preferAuthorDisplayName: true
|
||||||
|
@ -691,7 +695,7 @@ describe('Test config', function () {
|
||||||
expect(banners).to.have.lengthOf(2)
|
expect(banners).to.have.lengthOf(2)
|
||||||
|
|
||||||
for (const banner of banners) {
|
for (const banner of banners) {
|
||||||
await testImage(server.url, `banner-resized-${banner.width}`, banner.path)
|
await testImage({ url: banner.fileUrl, name: `banner-resized-${banner.width}.jpg` })
|
||||||
await testFileExistsOnFSOrNot(server, 'avatars', basename(banner.path), true)
|
await testFileExistsOnFSOrNot(server, 'avatars', basename(banner.path), true)
|
||||||
|
|
||||||
bannerPaths.push(banner.path)
|
bannerPaths.push(banner.path)
|
||||||
|
@ -763,6 +767,242 @@ describe('Test config', function () {
|
||||||
expect(object.icon).to.not.exist
|
expect(object.icon).to.not.exist
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Logos', function () {
|
||||||
|
describe('Favicon', function () {
|
||||||
|
const logoPaths: string[] = []
|
||||||
|
|
||||||
|
it('Should update instance favicon', async function () {
|
||||||
|
for (const extension of [ '.png', '.gif' ]) {
|
||||||
|
const fixture = 'avatar' + extension
|
||||||
|
|
||||||
|
await server.config.updateInstanceLogo({ type: 'favicon', fixture })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const favicons = htmlConfig.instance.logo.filter(l => l.type === 'favicon')
|
||||||
|
expect(favicons).to.have.lengthOf(1)
|
||||||
|
expect(favicons[0].width).to.equal(32)
|
||||||
|
expect(favicons[0].height).to.equal(32)
|
||||||
|
expect(favicons[0].isFallback).to.be.false
|
||||||
|
expect(favicons[0].type).to.equal('favicon')
|
||||||
|
|
||||||
|
logoPaths.push(favicons[0].fileUrl)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: favicons[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await testFileExistsOnFSOrNot(server, 'uploads/images', basename(favicons[0].fileUrl), true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should remove instance favicon', async function () {
|
||||||
|
await server.config.deleteInstanceLogo({ type: 'favicon' })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const favicons = htmlConfig.instance.logo.filter(l => l.type === 'favicon')
|
||||||
|
expect(favicons).to.have.lengthOf(1)
|
||||||
|
expect(favicons[0].width).to.equal(32)
|
||||||
|
expect(favicons[0].height).to.equal(32)
|
||||||
|
expect(favicons[0].isFallback).to.be.true
|
||||||
|
expect(favicons[0].type).to.equal('favicon')
|
||||||
|
|
||||||
|
await makeRawRequest({ url: favicons[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
for (const logoPath of logoPaths) {
|
||||||
|
await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logoPath), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Header square icons', function () {
|
||||||
|
const logoPaths: string[] = []
|
||||||
|
|
||||||
|
it('Should update instance header square icon', async function () {
|
||||||
|
for (const extension of [ '.png', '.gif' ]) {
|
||||||
|
const fixture = 'avatar' + extension
|
||||||
|
|
||||||
|
await server.config.updateInstanceLogo({ type: 'header-square', fixture })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-square')
|
||||||
|
expect(logos).to.have.lengthOf(1)
|
||||||
|
expect(logos[0].width).to.equal(48)
|
||||||
|
expect(logos[0].height).to.equal(48)
|
||||||
|
expect(logos[0].isFallback).to.be.false
|
||||||
|
expect(logos[0].type).to.equal('header-square')
|
||||||
|
|
||||||
|
logoPaths.push(logos[0].fileUrl)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logos[0].fileUrl), true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should remove instance header square icon', async function () {
|
||||||
|
await server.config.deleteInstanceLogo({ type: 'header-square' })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-square')
|
||||||
|
expect(logos).to.have.lengthOf(1)
|
||||||
|
expect(logos[0].width).to.equal(34)
|
||||||
|
expect(logos[0].height).to.equal(34)
|
||||||
|
expect(logos[0].isFallback).to.be.true
|
||||||
|
expect(logos[0].type).to.equal('header-square')
|
||||||
|
|
||||||
|
await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
for (const logoPath of logoPaths) {
|
||||||
|
await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logoPath), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Header wide icons', function () {
|
||||||
|
const logoPaths: string[] = []
|
||||||
|
|
||||||
|
it('Should update instance header wide icon', async function () {
|
||||||
|
const fixture = 'banner.jpg'
|
||||||
|
|
||||||
|
await server.config.updateInstanceLogo({ type: 'header-wide', fixture })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-wide')
|
||||||
|
expect(logos).to.have.lengthOf(1)
|
||||||
|
expect(logos[0].width).to.equal(258)
|
||||||
|
expect(logos[0].height).to.equal(48)
|
||||||
|
expect(logos[0].isFallback).to.be.false
|
||||||
|
expect(logos[0].type).to.equal('header-wide')
|
||||||
|
|
||||||
|
logoPaths.push(logos[0].fileUrl)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logos[0].fileUrl), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should remove instance header wide icon', async function () {
|
||||||
|
await server.config.deleteInstanceLogo({ type: 'header-wide' })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-wide')
|
||||||
|
expect(logos).to.have.lengthOf(1)
|
||||||
|
expect(logos[0].width).to.equal(34)
|
||||||
|
expect(logos[0].height).to.equal(34)
|
||||||
|
expect(logos[0].isFallback).to.be.true
|
||||||
|
expect(logos[0].type).to.equal('header-wide')
|
||||||
|
|
||||||
|
await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
for (const logoPath of logoPaths) {
|
||||||
|
await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logoPath), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Opengraph icons', function () {
|
||||||
|
it('Should update instance opengraph icon', async function () {
|
||||||
|
const fixture = 'banner.jpg'
|
||||||
|
|
||||||
|
await server.config.updateInstanceLogo({ type: 'opengraph', fixture })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const logos = htmlConfig.instance.logo.filter(l => l.type === 'opengraph')
|
||||||
|
expect(logos).to.have.lengthOf(1)
|
||||||
|
expect(logos[0].width).to.equal(1200)
|
||||||
|
expect(logos[0].height).to.equal(650)
|
||||||
|
expect(logos[0].isFallback).to.be.false
|
||||||
|
expect(logos[0].type).to.equal('opengraph')
|
||||||
|
|
||||||
|
await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logos[0].fileUrl), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should remove instance opengraph icon', async function () {
|
||||||
|
await server.config.deleteInstanceLogo({ type: 'opengraph' })
|
||||||
|
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const logos = htmlConfig.instance.logo.filter(l => l.type === 'opengraph')
|
||||||
|
expect(logos).to.have.lengthOf(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Default logo', function () {
|
||||||
|
before(async function () {
|
||||||
|
await server.config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: 'avatar.png' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should default to the avatar logo for the favicon, header icons and opengraph', async function () {
|
||||||
|
const htmlConfig = await server.config.getConfig()
|
||||||
|
|
||||||
|
const types: LogoType[] = [ 'favicon', 'header-square', 'header-wide', 'opengraph' ]
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
const logos = htmlConfig.instance.logo.filter(l => l.type === type)
|
||||||
|
|
||||||
|
expect(logos).to.have.lengthOf(4)
|
||||||
|
expect(logos[0].width).to.equal(48)
|
||||||
|
expect(logos[0].height).to.equal(48)
|
||||||
|
expect(logos[0].isFallback).to.be.true
|
||||||
|
expect(logos[0].type).to.equal(type)
|
||||||
|
|
||||||
|
await testImage({ url: logos[0].fileUrl, name: `avatar-resized-48x48.png` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await server.config.deleteInstanceImage({ type: ActorImageType.AVATAR })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Manifest', function () {
|
||||||
|
before(async function () {
|
||||||
|
await server.config.updateExistingConfig({
|
||||||
|
newConfig: {
|
||||||
|
instance: {
|
||||||
|
name: 'PeerTube manifest',
|
||||||
|
shortDescription: 'description manifest'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate the manifest file without avatar', async function () {
|
||||||
|
const { body } = await makeGetRequest({
|
||||||
|
url: server.url,
|
||||||
|
path: '/manifest.webmanifest',
|
||||||
|
expectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(body.name).to.equal('PeerTube manifest')
|
||||||
|
expect(body.short_name).to.equal(body.name)
|
||||||
|
expect(body.description).to.equal('description manifest')
|
||||||
|
|
||||||
|
const icon = body.icons.find(f => f.sizes === '36x36')
|
||||||
|
expect(icon).to.exist
|
||||||
|
expect(icon.src).to.equal('/client/assets/images/icons/icon-36x36.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate the manifest with avatar', async function () {
|
||||||
|
await server.config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: 'avatar.png' })
|
||||||
|
|
||||||
|
const { body } = await makeGetRequest({
|
||||||
|
url: server.url,
|
||||||
|
path: '/manifest.webmanifest',
|
||||||
|
expectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
|
||||||
|
const icon = body.icons.find(f => f.sizes === '48x48')
|
||||||
|
expect(icon).to.exist
|
||||||
|
|
||||||
|
await testImage({ url: server.url + icon.src, name: `avatar-resized-48x48.png` })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
|
|
|
@ -198,11 +198,11 @@ function runTest (withObjectStorage: boolean) {
|
||||||
expect(importedSecond.support).to.equal('noah support')
|
expect(importedSecond.support).to.equal('noah support')
|
||||||
|
|
||||||
for (const banner of importedSecond.banners) {
|
for (const banner of importedSecond.banners) {
|
||||||
await testImage(remoteServer.url, `banner-user-import-resized-${banner.width}`, banner.path)
|
await testImage({ url: banner.fileUrl, name: `banner-user-import-resized-${banner.width}.jpg` })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const avatar of importedSecond.avatars) {
|
for (const avatar of importedSecond.avatars) {
|
||||||
await testImage(remoteServer.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')
|
await testImage({ url: remoteServer.url + avatar.path, name: `avatar-resized-${avatar.width}x${avatar.width}.png` })
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { expect } from 'chai'
|
|
||||||
import { MyUser } from '@peertube/peertube-models'
|
import { MyUser } from '@peertube/peertube-models'
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
|
@ -14,7 +13,8 @@ import {
|
||||||
import { checkActorFilesWereRemoved } from '@tests/shared/actors.js'
|
import { checkActorFilesWereRemoved } from '@tests/shared/actors.js'
|
||||||
import { testImage } from '@tests/shared/checks.js'
|
import { testImage } from '@tests/shared/checks.js'
|
||||||
import { checkTmpIsEmpty } from '@tests/shared/directories.js'
|
import { checkTmpIsEmpty } from '@tests/shared/directories.js'
|
||||||
import { saveVideoInServers, checkVideoFilesWereRemoved } from '@tests/shared/videos.js'
|
import { checkVideoFilesWereRemoved, saveVideoInServers } from '@tests/shared/videos.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
describe('Test users with multiple servers', function () {
|
describe('Test users with multiple servers', function () {
|
||||||
let servers: PeerTubeServer[] = []
|
let servers: PeerTubeServer[] = []
|
||||||
|
@ -94,7 +94,7 @@ describe('Test users with multiple servers', function () {
|
||||||
userAvatarFilenames = user.account.avatars.map(({ path }) => path)
|
userAvatarFilenames = user.account.avatars.map(({ path }) => path)
|
||||||
|
|
||||||
for (const avatar of user.account.avatars) {
|
for (const avatar of user.account.avatars) {
|
||||||
await testImage(servers[0].url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')
|
await testImage({ url: servers[0].url + avatar.path, name: `avatar2-resized-${avatar.width}x${avatar.width}.png` })
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
@ -126,7 +126,7 @@ describe('Test users with multiple servers', function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const avatar of account.avatars) {
|
for (const avatar of account.avatars) {
|
||||||
await testImage(server.url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')
|
await testImage({ url: server.url + avatar.path, name: `avatar2-resized-${avatar.width}x${avatar.width}.png` })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -35,8 +35,8 @@ describe('Test video channels', function () {
|
||||||
let accountName: string
|
let accountName: string
|
||||||
let secondUserChannelName: string
|
let secondUserChannelName: string
|
||||||
|
|
||||||
const avatarPaths: { [ port: number ]: string } = {}
|
const avatarPaths: { [port: number]: string } = {}
|
||||||
const bannerPaths: { [ port: number ]: string } = {}
|
const bannerPaths: { [port: number]: string } = {}
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
this.timeout(60000)
|
this.timeout(60000)
|
||||||
|
@ -293,7 +293,7 @@ describe('Test video channels', function () {
|
||||||
|
|
||||||
for (const avatar of videoChannel.avatars) {
|
for (const avatar of videoChannel.avatars) {
|
||||||
avatarPaths[server.port] = avatar.path
|
avatarPaths[server.port] = avatar.path
|
||||||
await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatarPaths[server.port], '.png')
|
await testImage({ url: server.url + avatarPaths[server.port], name: `avatar-resized-${avatar.width}x${avatar.width}.png` })
|
||||||
await testFileExistsOnFSOrNot(server, 'avatars', basename(avatarPaths[server.port]), true)
|
await testFileExistsOnFSOrNot(server, 'avatars', basename(avatarPaths[server.port]), true)
|
||||||
|
|
||||||
const row = await sqlCommands[i].getActorImage(basename(avatarPaths[server.port]))
|
const row = await sqlCommands[i].getActorImage(basename(avatarPaths[server.port]))
|
||||||
|
@ -326,7 +326,7 @@ describe('Test video channels', function () {
|
||||||
|
|
||||||
for (const banner of videoChannel.banners) {
|
for (const banner of videoChannel.banners) {
|
||||||
bannerPaths[server.port] = banner.path
|
bannerPaths[server.port] = banner.path
|
||||||
await testImage(server.url, `banner-resized-${banner.width}`, bannerPaths[server.port])
|
await testImage({ url: server.url + bannerPaths[server.port], name: `banner-resized-${banner.width}.jpg` })
|
||||||
await testFileExistsOnFSOrNot(server, 'avatars', basename(bannerPaths[server.port]), true)
|
await testFileExistsOnFSOrNot(server, 'avatars', basename(bannerPaths[server.port]), true)
|
||||||
|
|
||||||
const row = await sqlCommands[i].getActorImage(basename(bannerPaths[server.port]))
|
const row = await sqlCommands[i].getActorImage(basename(bannerPaths[server.port]))
|
||||||
|
|
|
@ -92,7 +92,7 @@ describe('Test video comments', function () {
|
||||||
expect(comment.account.host).to.equal(server.host)
|
expect(comment.account.host).to.equal(server.host)
|
||||||
|
|
||||||
for (const avatar of comment.account.avatars) {
|
for (const avatar of comment.account.avatars) {
|
||||||
await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')
|
await testImage({ url: server.url + avatar.path, name: `avatar-resized-${avatar.width}x${avatar.width}.png` })
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(comment.totalReplies).to.equal(0)
|
expect(comment.totalReplies).to.equal(0)
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { config, expect } from 'chai'
|
import { findAppropriateImage } from '@peertube/peertube-core-utils'
|
||||||
import { Account, HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
|
import { Account, HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
|
||||||
import { cleanupTests, makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
|
import { cleanupTests, makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
|
||||||
import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
|
import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
|
||||||
|
import { config, expect } from 'chai'
|
||||||
|
|
||||||
config.truncateThreshold = 0
|
config.truncateThreshold = 0
|
||||||
|
|
||||||
|
@ -47,6 +48,32 @@ describe('Test <head> HTML tags', function () {
|
||||||
} = await prepareClientTests())
|
} = await prepareClientTests())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Icons', function () {
|
||||||
|
async function indexPageTest (path: string) {
|
||||||
|
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
const text = res.text
|
||||||
|
|
||||||
|
const config = await servers[0].config.getConfig()
|
||||||
|
|
||||||
|
{
|
||||||
|
const favicon = config.instance.logo.find(l => l.type === 'favicon')
|
||||||
|
expect(text).to.contain(`<link rel="icon" type="image/png" href="${favicon.fileUrl}" />`)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const appleTouchIcon = findAppropriateImage(config.instance.avatars, 192)
|
||||||
|
expect(text).to.contain(`<link rel="apple-touch-icon" href="${appleTouchIcon.fileUrl}" />`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should have valid favicon/ Graph tags on the common page', async function () {
|
||||||
|
await indexPageTest('/about/peertube')
|
||||||
|
await indexPageTest('/videos')
|
||||||
|
await indexPageTest('/homepage')
|
||||||
|
await indexPageTest('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Open Graph', function () {
|
describe('Open Graph', function () {
|
||||||
async function indexPageTest (path: string) {
|
async function indexPageTest (path: string) {
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
@ -160,6 +187,15 @@ describe('Test <head> HTML tags', function () {
|
||||||
await servers[0].config.updateCustomConfig({ newCustomConfig: config })
|
await servers[0].config.updateCustomConfig({ newCustomConfig: config })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function indexPageTest (path: string) {
|
||||||
|
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
const text = res.text
|
||||||
|
|
||||||
|
expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
|
||||||
|
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
|
||||||
|
expect(text).to.contain(`<meta property="twitter:image:url" content="${servers[0].url}`)
|
||||||
|
}
|
||||||
|
|
||||||
async function accountPageTest (path: string) {
|
async function accountPageTest (path: string) {
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
||||||
const text = res.text
|
const text = res.text
|
||||||
|
@ -196,6 +232,13 @@ describe('Test <head> HTML tags', function () {
|
||||||
expect(text).to.contain(`<meta property="twitter:image:url" content="${servers[0].url}`)
|
expect(text).to.contain(`<meta property="twitter:image:url" content="${servers[0].url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('Should have valid Open Graph tags on the common page', async function () {
|
||||||
|
await indexPageTest('/about/peertube')
|
||||||
|
await indexPageTest('/videos')
|
||||||
|
await indexPageTest('/homepage')
|
||||||
|
await indexPageTest('/')
|
||||||
|
})
|
||||||
|
|
||||||
it('Should have valid twitter card on the watch video page', async function () {
|
it('Should have valid twitter card on the watch video page', async function () {
|
||||||
for (const path of getWatchVideoBasePaths()) {
|
for (const path of getWatchVideoBasePaths()) {
|
||||||
for (const id of videoIds) {
|
for (const id of videoIds) {
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
|
||||||
|
|
||||||
|
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||||
|
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
|
||||||
|
import { makeGetRequest, makeRawRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { pathExists } from 'fs-extra/esm'
|
import { pathExists } from 'fs-extra/esm'
|
||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import { join, parse } from 'path'
|
import { join, parse } from 'path'
|
||||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
|
||||||
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
|
|
||||||
import { makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
|
|
||||||
|
|
||||||
// Default interval -> 5 minutes
|
// Default interval -> 5 minutes
|
||||||
function dateIsValid (dateString: string | Date, interval = 300000) {
|
function dateIsValid (dateString: string | Date, interval = 300000) {
|
||||||
|
@ -75,36 +75,36 @@ async function testImageGeneratedByFFmpeg (url: string, imageName: string, image
|
||||||
if (process.env.ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS !== 'true') {
|
if (process.env.ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS !== 'true') {
|
||||||
console.log(
|
console.log(
|
||||||
'Pixel comparison of image generated by ffmpeg is disabled. ' +
|
'Pixel comparison of image generated by ffmpeg is disabled. ' +
|
||||||
'You can enable it using `ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS=true env variable')
|
'You can enable it using `ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS=true env variable'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return testImage(url, imageName, imageHTTPPath, extension)
|
return testImage({ url: url + imageHTTPPath, name: `${imageName}${extension}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testImage (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') {
|
async function testImage (options: {
|
||||||
const res = await makeGetRequest({
|
name: string
|
||||||
url,
|
url: string
|
||||||
path: imageHTTPPath,
|
}) {
|
||||||
expectedStatus: HttpStatusCode.OK_200
|
const { name, url } = options
|
||||||
})
|
|
||||||
|
|
||||||
const body = res.body
|
const { body } = await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
const data = await readFile(buildAbsoluteFixturePath(imageName + extension))
|
const data = await readFile(buildAbsoluteFixturePath(name))
|
||||||
|
|
||||||
const { PNG } = await import('pngjs')
|
const { PNG } = await import('pngjs')
|
||||||
const JPEG = await import('jpeg-js')
|
const JPEG = await import('jpeg-js')
|
||||||
const pixelmatch = (await import('pixelmatch')).default
|
const pixelmatch = (await import('pixelmatch')).default
|
||||||
|
|
||||||
const img1 = imageHTTPPath.endsWith('.png')
|
const img1 = url.endsWith('.png')
|
||||||
? PNG.sync.read(body)
|
? PNG.sync.read(body)
|
||||||
: JPEG.decode(body)
|
: JPEG.decode(body)
|
||||||
|
|
||||||
const img2 = extension === '.png'
|
const img2 = name.endsWith('.png')
|
||||||
? PNG.sync.read(data)
|
? PNG.sync.read(data)
|
||||||
: JPEG.decode(data)
|
: JPEG.decode(data)
|
||||||
|
|
||||||
const errorMsg = `${imageHTTPPath} image is not the same as ${imageName}${extension}`
|
const errorMsg = `${url} image is not the same as ${name}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = pixelmatch(img1.data, img2.data, null, img1.width, img1.height, { threshold: 0.1 })
|
const result = pixelmatch(img1.data, img2.data, null, img1.width, img1.height, { threshold: 0.1 })
|
||||||
|
@ -178,18 +178,18 @@ async function checkVideoDuration (server: PeerTubeServer, videoUUID: string, du
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
dateIsValid,
|
|
||||||
testImageGeneratedByFFmpeg,
|
|
||||||
testAvatarSize,
|
|
||||||
testImage,
|
|
||||||
expectLogDoesNotContain,
|
|
||||||
testFileExistsOnFSOrNot,
|
|
||||||
expectStartWith,
|
|
||||||
expectNotStartWith,
|
|
||||||
expectEndWith,
|
|
||||||
checkBadStartPagination,
|
|
||||||
checkBadCountPagination,
|
checkBadCountPagination,
|
||||||
checkBadSortPagination,
|
checkBadSortPagination,
|
||||||
|
checkBadStartPagination,
|
||||||
checkVideoDuration,
|
checkVideoDuration,
|
||||||
expectLogContain
|
dateIsValid,
|
||||||
|
expectEndWith,
|
||||||
|
expectLogContain,
|
||||||
|
expectLogDoesNotContain,
|
||||||
|
expectNotStartWith,
|
||||||
|
expectStartWith,
|
||||||
|
testAvatarSize,
|
||||||
|
testFileExistsOnFSOrNot,
|
||||||
|
testImage,
|
||||||
|
testImageGeneratedByFFmpeg
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
import { omit, pick } from '@peertube/peertube-core-utils'
|
import { omit, pick } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
VideoPrivacy,
|
|
||||||
VideoPlaylistPrivacy,
|
|
||||||
VideoPlaylistCreateResult,
|
|
||||||
Account,
|
Account,
|
||||||
|
ActorImageType,
|
||||||
HTMLServerConfig,
|
HTMLServerConfig,
|
||||||
|
LogoType,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
ActorImageType
|
VideoPlaylistCreateResult,
|
||||||
|
VideoPlaylistPrivacy,
|
||||||
|
VideoPrivacy
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import {
|
import {
|
||||||
createMultipleServers,
|
createMultipleServers,
|
||||||
setAccessTokensToServers,
|
|
||||||
doubleFollow,
|
doubleFollow,
|
||||||
|
setAccessTokensToServers,
|
||||||
setDefaultVideoChannel,
|
setDefaultVideoChannel,
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
|
@ -60,6 +61,11 @@ export async function prepareClientTests () {
|
||||||
})
|
})
|
||||||
await servers[0].config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: instanceConfig.avatar })
|
await servers[0].config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: instanceConfig.avatar })
|
||||||
|
|
||||||
|
const types: LogoType[] = [ 'favicon', 'header-square', 'header-wide', 'opengraph' ]
|
||||||
|
for (const type of types) {
|
||||||
|
await servers[0].config.updateInstanceLogo({ type, fixture: 'avatar.png' })
|
||||||
|
}
|
||||||
|
|
||||||
let account: Account
|
let account: Account
|
||||||
|
|
||||||
let videoIds: (string | number)[] = []
|
let videoIds: (string | number)[] = []
|
||||||
|
@ -104,10 +110,10 @@ export async function prepareClientTests () {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
|
;({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }))
|
||||||
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
|
;({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }))
|
||||||
({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
|
;({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }))
|
||||||
({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
|
;({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
|
||||||
name: 'password protected',
|
name: 'password protected',
|
||||||
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
videoPasswords: [ 'password' ]
|
videoPasswords: [ 'password' ]
|
||||||
|
|
|
@ -77,7 +77,6 @@ if [ -z ${1+x} ] || ([ "$1" != "--light" ] && [ "$1" != "--analyze-bundle" ]); t
|
||||||
mv "./dist/$defaultLanguage/assets" "./dist"
|
mv "./dist/$defaultLanguage/assets" "./dist"
|
||||||
|
|
||||||
rm -r "dist/build"
|
rm -r "dist/build"
|
||||||
cp "./dist/$defaultLanguage/manifest.webmanifest" "./dist/manifest.webmanifest"
|
|
||||||
else
|
else
|
||||||
additionalParams=""
|
additionalParams=""
|
||||||
if [ ! -z ${1+x} ] && [ "$1" == "--analyze-bundle" ]; then
|
if [ ! -z ${1+x} ] && [ "$1" == "--analyze-bundle" ]; then
|
||||||
|
|
|
@ -11,3 +11,5 @@ npm run resolve-tspaths:server
|
||||||
|
|
||||||
cp -r "./server/core/static" "./server/core/assets" ./dist/core
|
cp -r "./server/core/static" "./server/core/assets" ./dist/core
|
||||||
cp "./server/scripts/upgrade.sh" "./dist/scripts"
|
cp "./server/scripts/upgrade.sh" "./dist/scripts"
|
||||||
|
|
||||||
|
mkdir -p ./client/dist && cp -r ./client/src/assets ./client/dist
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models'
|
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, LogoType, UserRight } from '@peertube/peertube-models'
|
||||||
import { createReqFiles } from '@server/helpers/express-utils.js'
|
import { createReqFiles } from '@server/helpers/express-utils.js'
|
||||||
import { MIMETYPES } from '@server/initializers/constants.js'
|
import { MIMETYPES } from '@server/initializers/constants.js'
|
||||||
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '@server/lib/local-actor.js'
|
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '@server/lib/local-actor.js'
|
||||||
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
|
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
|
||||||
|
import { deleteUploadImages, logoTypeToUploadImageEnum, replaceUploadImage } from '@server/lib/upload-image.js'
|
||||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||||
import { getServerActor } from '@server/models/application/application.js'
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
import { ModelCache } from '@server/models/shared/model-cache.js'
|
import { ModelCache } from '@server/models/shared/model-cache.js'
|
||||||
|
@ -23,7 +24,12 @@ import {
|
||||||
updateAvatarValidator,
|
updateAvatarValidator,
|
||||||
updateBannerValidator
|
updateBannerValidator
|
||||||
} from '../../middlewares/index.js'
|
} from '../../middlewares/index.js'
|
||||||
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
|
import {
|
||||||
|
customConfigUpdateValidator,
|
||||||
|
ensureConfigIsEditable,
|
||||||
|
updateInstanceLogoValidator,
|
||||||
|
updateOrDeleteLogoValidator
|
||||||
|
} from '../../middlewares/validators/config.js'
|
||||||
|
|
||||||
const configRouter = express.Router()
|
const configRouter = express.Router()
|
||||||
|
|
||||||
|
@ -100,6 +106,26 @@ configRouter.delete(
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
configRouter.post(
|
||||||
|
'/instance-logo/:logoType/pick',
|
||||||
|
authenticate,
|
||||||
|
createReqFiles([ 'logofile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
|
||||||
|
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
|
||||||
|
updateOrDeleteLogoValidator,
|
||||||
|
updateInstanceLogoValidator,
|
||||||
|
asyncMiddleware(updateInstanceLogo)
|
||||||
|
)
|
||||||
|
|
||||||
|
configRouter.delete(
|
||||||
|
'/instance-logo/:logoType',
|
||||||
|
authenticate,
|
||||||
|
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
|
||||||
|
updateOrDeleteLogoValidator,
|
||||||
|
asyncMiddleware(deleteInstanceLogo)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getConfig (req: express.Request, res: express.Response) {
|
async function getConfig (req: express.Request, res: express.Response) {
|
||||||
const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
|
const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
|
||||||
|
|
||||||
|
@ -187,13 +213,17 @@ function updateInstanceImageFactory (imageType: ActorImageType_Type) {
|
||||||
|
|
||||||
const imagePhysicalFile = req.files[field][0]
|
const imagePhysicalFile = req.files[field][0]
|
||||||
|
|
||||||
|
const serverActor = await getServerActor()
|
||||||
|
|
||||||
await updateLocalActorImageFiles({
|
await updateLocalActorImageFiles({
|
||||||
accountOrChannel: (await getServerActorWithUpdatedImages(imageType)).Account,
|
accountOrChannel: serverActor.Account,
|
||||||
imagePhysicalFile,
|
imagePhysicalFile,
|
||||||
type: imageType,
|
type: imageType,
|
||||||
sendActorUpdate: false
|
sendActorUpdate: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await updateServerActorImages(imageType)
|
||||||
|
|
||||||
ClientHtml.invalidateCache()
|
ClientHtml.invalidateCache()
|
||||||
ModelCache.Instance.clearCache('server-account')
|
ModelCache.Instance.clearCache('server-account')
|
||||||
|
|
||||||
|
@ -203,7 +233,11 @@ function updateInstanceImageFactory (imageType: ActorImageType_Type) {
|
||||||
|
|
||||||
function deleteInstanceImageFactory (imageType: ActorImageType_Type) {
|
function deleteInstanceImageFactory (imageType: ActorImageType_Type) {
|
||||||
return async (req: express.Request, res: express.Response) => {
|
return async (req: express.Request, res: express.Response) => {
|
||||||
await deleteLocalActorImageFile((await getServerActorWithUpdatedImages(imageType)).Account, imageType)
|
const serverActor = await getServerActor()
|
||||||
|
|
||||||
|
await deleteLocalActorImageFile(serverActor.Account, imageType)
|
||||||
|
|
||||||
|
await updateServerActorImages(imageType)
|
||||||
|
|
||||||
ClientHtml.invalidateCache()
|
ClientHtml.invalidateCache()
|
||||||
ModelCache.Instance.clearCache('server-account')
|
ModelCache.Instance.clearCache('server-account')
|
||||||
|
@ -212,7 +246,7 @@ function deleteInstanceImageFactory (imageType: ActorImageType_Type) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getServerActorWithUpdatedImages (imageType: ActorImageType_Type) {
|
async function updateServerActorImages (imageType: ActorImageType_Type) {
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
const updatedImages = await ActorImageModel.listByActor(serverActor, imageType) // Reload images from DB
|
const updatedImages = await ActorImageModel.listByActor(serverActor, imageType) // Reload images from DB
|
||||||
|
|
||||||
|
@ -224,6 +258,35 @@ async function getServerActorWithUpdatedImages (imageType: ActorImageType_Type)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function updateInstanceLogo (req: express.Request, res: express.Response) {
|
||||||
|
const imagePhysicalFile = req.files['logofile'][0]
|
||||||
|
|
||||||
|
await replaceUploadImage({
|
||||||
|
actor: await getServerActor(),
|
||||||
|
imagePhysicalFile,
|
||||||
|
type: logoTypeToUploadImageEnum(req.params.logoType as LogoType)
|
||||||
|
})
|
||||||
|
|
||||||
|
ClientHtml.invalidateCache()
|
||||||
|
ModelCache.Instance.clearCache('server-account')
|
||||||
|
|
||||||
|
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInstanceLogo (req: express.Request, res: express.Response) {
|
||||||
|
await deleteUploadImages({
|
||||||
|
actor: await getServerActor(),
|
||||||
|
type: logoTypeToUploadImageEnum(req.params.logoType as LogoType)
|
||||||
|
})
|
||||||
|
|
||||||
|
ClientHtml.invalidateCache()
|
||||||
|
ModelCache.Instance.clearCache('server-account')
|
||||||
|
|
||||||
|
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
configRouter
|
configRouter
|
||||||
}
|
}
|
||||||
|
@ -293,6 +356,9 @@ function customConfig (): CustomConfig {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
|
header: {
|
||||||
|
hideInstanceName: CONFIG.CLIENT.HEADER.HIDE_INSTANCE_NAME
|
||||||
|
},
|
||||||
videos: {
|
videos: {
|
||||||
miniature: {
|
miniature: {
|
||||||
preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
|
preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import express from 'express'
|
|
||||||
import { constants, promises as fs } from 'fs'
|
|
||||||
import { readFile } from 'fs/promises'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '@peertube/peertube-core-utils'
|
import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '@peertube/peertube-core-utils'
|
||||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||||
|
import { currentDir, root } from '@peertube/peertube-node-utils'
|
||||||
import { logger } from '@server/helpers/logger.js'
|
import { logger } from '@server/helpers/logger.js'
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import { currentDir, root } from '@peertube/peertube-node-utils'
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
|
import express from 'express'
|
||||||
|
import { constants, promises as fs } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
import { STATIC_MAX_AGE } from '../initializers/constants.js'
|
import { STATIC_MAX_AGE } from '../initializers/constants.js'
|
||||||
import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/html/client-html.js'
|
import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/html/client-html.js'
|
||||||
import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares/index.js'
|
import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares/index.js'
|
||||||
|
@ -20,7 +20,6 @@ const clientsRateLimiter = buildRateLimiter({
|
||||||
})
|
})
|
||||||
|
|
||||||
const distPath = join(root(), 'client', 'dist')
|
const distPath = join(root(), 'client', 'dist')
|
||||||
const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
|
|
||||||
|
|
||||||
// Special route that add OpenGraph and oEmbed tags
|
// Special route that add OpenGraph and oEmbed tags
|
||||||
// Do not use a template engine for a so little thing
|
// Do not use a template engine for a so little thing
|
||||||
|
@ -59,6 +58,7 @@ clientsRouter.use('/video-playlists/embed/:id', ...embedMiddlewares, asyncMiddle
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
|
||||||
const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
|
const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
|
||||||
|
|
||||||
clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
|
clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
|
||||||
|
@ -72,15 +72,6 @@ clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(g
|
||||||
// Static client overrides
|
// Static client overrides
|
||||||
// Must be consistent with static client overrides redirections in /support/nginx/peertube
|
// Must be consistent with static client overrides redirections in /support/nginx/peertube
|
||||||
const staticClientOverrides = [
|
const staticClientOverrides = [
|
||||||
'assets/images/logo.svg',
|
|
||||||
'assets/images/favicon.png',
|
|
||||||
'assets/images/icons/icon-36x36.png',
|
|
||||||
'assets/images/icons/icon-48x48.png',
|
|
||||||
'assets/images/icons/icon-72x72.png',
|
|
||||||
'assets/images/icons/icon-96x96.png',
|
|
||||||
'assets/images/icons/icon-144x144.png',
|
|
||||||
'assets/images/icons/icon-192x192.png',
|
|
||||||
'assets/images/icons/icon-512x512.png',
|
|
||||||
'assets/images/default-playlist.jpg',
|
'assets/images/default-playlist.jpg',
|
||||||
'assets/images/default-avatar-account.png',
|
'assets/images/default-avatar-account.png',
|
||||||
'assets/images/default-avatar-account-48x48.png',
|
'assets/images/default-avatar-account-48x48.png',
|
||||||
|
@ -206,15 +197,34 @@ async function generateActorHtmlPage (req: express.Request, res: express.Respons
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateManifest (req: express.Request, res: express.Response) {
|
async function generateManifest (req: express.Request, res: express.Response) {
|
||||||
const manifestPhysicalPath = join(root(), 'client', 'dist', 'manifest.webmanifest')
|
const serverActor = await getServerActor()
|
||||||
const manifestJson = await readFile(manifestPhysicalPath, 'utf8')
|
|
||||||
const manifest = JSON.parse(manifestJson)
|
|
||||||
|
|
||||||
manifest.name = CONFIG.INSTANCE.NAME
|
const defaultIcons = [ 36, 48, 72, 96, 144, 192, 512 ].map(size => {
|
||||||
manifest.short_name = CONFIG.INSTANCE.NAME
|
return {
|
||||||
manifest.description = CONFIG.INSTANCE.SHORT_DESCRIPTION
|
src: `/client/assets/images/icons/icon-${size}x${size}.png`,
|
||||||
|
sizes: `36x36`,
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
res.json(manifest)
|
const icons = Array.isArray(serverActor.Avatars) && serverActor.Avatars.length > 0
|
||||||
|
? serverActor.Avatars.map(avatar => ({
|
||||||
|
src: avatar.getStaticPath(),
|
||||||
|
sizes: `${avatar.width}x${avatar.height}`,
|
||||||
|
type: avatar.getMimeType()
|
||||||
|
}))
|
||||||
|
: defaultIcons
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
name: CONFIG.INSTANCE.NAME,
|
||||||
|
short_name: CONFIG.INSTANCE.NAME,
|
||||||
|
start_url: '/',
|
||||||
|
background_color: '#fff',
|
||||||
|
theme_color: '#fff',
|
||||||
|
description: CONFIG.INSTANCE.SHORT_DESCRIPTION,
|
||||||
|
display: 'standalone',
|
||||||
|
icons
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveClientOverride (path: string) {
|
function serveClientOverride (path: string) {
|
||||||
|
|
|
@ -23,7 +23,8 @@ const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
commentFeedsRouter.get('/video-comments.:format',
|
commentFeedsRouter.get(
|
||||||
|
'/video-comments.:format',
|
||||||
feedsFormatValidator,
|
feedsFormatValidator,
|
||||||
setFeedFormatContentType,
|
setFeedFormatContentType,
|
||||||
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
|
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
|
||||||
|
@ -56,7 +57,7 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res
|
||||||
|
|
||||||
const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
|
const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
|
||||||
|
|
||||||
const feed = initFeed({
|
const feed = await initFeed({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
|
|
@ -5,11 +5,13 @@ import { ActorImageType } from '@peertube/peertube-models'
|
||||||
import { mdToPlainText } from '@server/helpers/markdown.js'
|
import { mdToPlainText } from '@server/helpers/markdown.js'
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { WEBSERVER } from '@server/initializers/constants.js'
|
import { WEBSERVER } from '@server/initializers/constants.js'
|
||||||
|
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
|
||||||
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
import { UserModel } from '@server/models/user/user.js'
|
import { UserModel } from '@server/models/user/user.js'
|
||||||
import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models/index.js'
|
import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models/index.js'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
|
||||||
export function initFeed (parameters: {
|
export async function initFeed (parameters: {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
|
@ -46,10 +48,10 @@ export function initFeed (parameters: {
|
||||||
|
|
||||||
image: imageUrl,
|
image: imageUrl,
|
||||||
|
|
||||||
favicon: webserverUrl + '/client/assets/images/favicon.png',
|
favicon: ServerConfigManager.Instance.getFavicon(await getServerActor()).fileUrl,
|
||||||
|
|
||||||
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
|
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
|
||||||
` and potential licenses granted by each content's rightholder.`,
|
` and potential licenses granted by each content's rightholder.`,
|
||||||
|
|
||||||
generator: `PeerTube - ${webserverUrl}`,
|
generator: `PeerTube - ${webserverUrl}`,
|
||||||
|
|
||||||
|
|
|
@ -73,7 +73,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
|
||||||
|
|
||||||
const { name, description, imageUrl, ownerImageUrl, link, ownerLink } = await buildFeedMetadata({ videoChannel, account })
|
const { name, description, imageUrl, ownerImageUrl, link, ownerLink } = await buildFeedMetadata({ videoChannel, account })
|
||||||
|
|
||||||
const feed = initFeed({
|
const feed = await initFeed({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
link,
|
link,
|
||||||
|
@ -114,7 +114,7 @@ async function generateVideoFeedForSubscriptions (req: express.Request, res: exp
|
||||||
const account = res.locals.account
|
const account = res.locals.account
|
||||||
const { name, description, imageUrl, link } = await buildFeedMetadata({ account })
|
const { name, description, imageUrl, link } = await buildFeedMetadata({ account })
|
||||||
|
|
||||||
const feed = initFeed({
|
const feed = await initFeed({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
link,
|
link,
|
||||||
|
|
|
@ -95,7 +95,7 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
|
||||||
'filter:feed.podcast.rss.create-custom-xmlns.result'
|
'filter:feed.podcast.rss.create-custom-xmlns.result'
|
||||||
)
|
)
|
||||||
|
|
||||||
const feed = initFeed({
|
const feed = await initFeed({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
link,
|
link,
|
||||||
|
|
|
@ -78,6 +78,14 @@ staticRouter.use(
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// Uploads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
staticRouter.use(
|
||||||
|
STATIC_PATHS.UPLOAD_IMAGES,
|
||||||
|
express.static(DIRECTORIES.UPLOAD_IMAGES, { fallthrough: false }),
|
||||||
|
handleStaticError
|
||||||
|
)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
staticRouter
|
staticRouter
|
||||||
|
|
|
@ -7,7 +7,7 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
|
||||||
.join('|')
|
.join('|')
|
||||||
const imageMimeTypesRegex = `image/(${imageMimeTypes})`
|
const imageMimeTypesRegex = `image/(${imageMimeTypes})`
|
||||||
|
|
||||||
function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
|
export function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
|
||||||
return isFileValid({
|
return isFileValid({
|
||||||
files,
|
files,
|
||||||
mimeTypeRegex: imageMimeTypesRegex,
|
mimeTypeRegex: imageMimeTypesRegex,
|
||||||
|
@ -15,9 +15,3 @@ function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
|
||||||
maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
|
maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export {
|
|
||||||
isActorImageFile
|
|
||||||
}
|
|
||||||
|
|
7
server/core/helpers/custom-validators/config.ts
Normal file
7
server/core/helpers/custom-validators/config.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { LogoType } from '@peertube/peertube-models'
|
||||||
|
|
||||||
|
const logoTypes = new Set<LogoType>([ 'favicon', 'header-square', 'header-wide', 'opengraph' ])
|
||||||
|
|
||||||
|
export function isConfigLogoTypeValid (value: LogoType) {
|
||||||
|
return logoTypes.has(value)
|
||||||
|
}
|
|
@ -52,6 +52,21 @@ export async function getImageSize (path: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build new size if height or width is missing, to keep the aspect ratio
|
||||||
|
export async function buildImageSize (imagePath: string, sizeArg: { width?: number, height?: number }) {
|
||||||
|
if (sizeArg.width && sizeArg.height) {
|
||||||
|
return sizeArg as { width: number, height: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = await getImageSize(imagePath)
|
||||||
|
const ratio = size.width / size.height
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: sizeArg.width ?? Math.round(sizeArg.height * ratio),
|
||||||
|
height: sizeArg.height ?? Math.round(sizeArg.width / ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Private
|
// Private
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -42,6 +42,7 @@ export function checkMissedConfig () {
|
||||||
'storage.streaming_playlists',
|
'storage.streaming_playlists',
|
||||||
'storage.plugins',
|
'storage.plugins',
|
||||||
'storage.well_known',
|
'storage.well_known',
|
||||||
|
'storage.uploads',
|
||||||
'log.level',
|
'log.level',
|
||||||
'log.rotation.enabled',
|
'log.rotation.enabled',
|
||||||
'log.rotation.max_file_size',
|
'log.rotation.max_file_size',
|
||||||
|
@ -124,6 +125,7 @@ export function checkMissedConfig () {
|
||||||
'trending.videos.interval_days',
|
'trending.videos.interval_days',
|
||||||
'client.videos.miniature.prefer_author_display_name',
|
'client.videos.miniature.prefer_author_display_name',
|
||||||
'client.menu.login.redirect_on_single_external_auth',
|
'client.menu.login.redirect_on_single_external_auth',
|
||||||
|
'client.header.hide_instance_name',
|
||||||
'defaults.publish.download_enabled',
|
'defaults.publish.download_enabled',
|
||||||
'defaults.publish.comments_policy',
|
'defaults.publish.comments_policy',
|
||||||
'defaults.publish.privacy',
|
'defaults.publish.privacy',
|
||||||
|
|
|
@ -75,6 +75,11 @@ const CONFIG = {
|
||||||
},
|
},
|
||||||
|
|
||||||
CLIENT: {
|
CLIENT: {
|
||||||
|
HEADER: {
|
||||||
|
get HIDE_INSTANCE_NAME () {
|
||||||
|
return config.get<boolean>('client.header.hide_instance_name')
|
||||||
|
}
|
||||||
|
},
|
||||||
VIDEOS: {
|
VIDEOS: {
|
||||||
MINIATURE: {
|
MINIATURE: {
|
||||||
get PREFER_AUTHOR_DISPLAY_NAME () {
|
get PREFER_AUTHOR_DISPLAY_NAME () {
|
||||||
|
@ -180,7 +185,8 @@ const CONFIG = {
|
||||||
CACHE_DIR: buildPath(config.get<string>('storage.cache')),
|
CACHE_DIR: buildPath(config.get<string>('storage.cache')),
|
||||||
PLUGINS_DIR: buildPath(config.get<string>('storage.plugins')),
|
PLUGINS_DIR: buildPath(config.get<string>('storage.plugins')),
|
||||||
CLIENT_OVERRIDES_DIR: buildPath(config.get<string>('storage.client_overrides')),
|
CLIENT_OVERRIDES_DIR: buildPath(config.get<string>('storage.client_overrides')),
|
||||||
WELL_KNOWN_DIR: buildPath(config.get<string>('storage.well_known'))
|
WELL_KNOWN_DIR: buildPath(config.get<string>('storage.well_known')),
|
||||||
|
UPLOADS_DIR: buildPath(config.get<string>('storage.uploads'))
|
||||||
},
|
},
|
||||||
STATIC_FILES: {
|
STATIC_FILES: {
|
||||||
PRIVATE_FILES_REQUIRE_AUTH: config.get<boolean>('static_files.private_files_require_auth')
|
PRIVATE_FILES_REQUIRE_AUTH: config.get<boolean>('static_files.private_files_require_auth')
|
||||||
|
|
|
@ -10,6 +10,8 @@ import {
|
||||||
NSFWPolicyType,
|
NSFWPolicyType,
|
||||||
RunnerJobState,
|
RunnerJobState,
|
||||||
RunnerJobStateType,
|
RunnerJobStateType,
|
||||||
|
UploadImageType,
|
||||||
|
UploadImageType_Type,
|
||||||
UserExportState,
|
UserExportState,
|
||||||
UserExportStateType,
|
UserExportStateType,
|
||||||
UserImportState,
|
UserImportState,
|
||||||
|
@ -46,7 +48,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const LAST_MIGRATION_VERSION = 895
|
export const LAST_MIGRATION_VERSION = 890
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -865,7 +867,9 @@ export const STATIC_PATHS = {
|
||||||
STREAMING_PLAYLISTS: {
|
STREAMING_PLAYLISTS: {
|
||||||
HLS: '/static/streaming-playlists/hls',
|
HLS: '/static/streaming-playlists/hls',
|
||||||
PRIVATE_HLS: '/static/streaming-playlists/hls/private/'
|
PRIVATE_HLS: '/static/streaming-playlists/hls/private/'
|
||||||
}
|
},
|
||||||
|
|
||||||
|
UPLOAD_IMAGES: '/static/uploads/images/'
|
||||||
}
|
}
|
||||||
export const DOWNLOAD_PATHS = {
|
export const DOWNLOAD_PATHS = {
|
||||||
TORRENTS: '/download/torrents/',
|
TORRENTS: '/download/torrents/',
|
||||||
|
@ -942,6 +946,32 @@ export const ACTOR_IMAGES_SIZE: { [key in ActorImageType_Type]: { width: number,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
export const UPLOAD_IMAGES_SIZE: { [key in UploadImageType_Type]: { width: number, height: number }[] } = {
|
||||||
|
[UploadImageType.INSTANCE_FAVICON]: [
|
||||||
|
{
|
||||||
|
width: 32,
|
||||||
|
height: 32
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[UploadImageType.INSTANCE_HEADER_SQUARE]: [
|
||||||
|
{
|
||||||
|
width: 48,
|
||||||
|
height: 48
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[UploadImageType.INSTANCE_HEADER_WIDE]: [
|
||||||
|
{
|
||||||
|
width: null, // Auto
|
||||||
|
height: 48
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[UploadImageType.INSTANCE_OPENGRAPH]: [
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 650
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export const STORYBOARD = {
|
export const STORYBOARD = {
|
||||||
SPRITE_MAX_SIZE: 192,
|
SPRITE_MAX_SIZE: 192,
|
||||||
|
@ -1014,7 +1044,9 @@ export const DIRECTORIES = {
|
||||||
|
|
||||||
HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls'),
|
HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls'),
|
||||||
|
|
||||||
LOCAL_PIP_DIRECTORY: join(CONFIG.STORAGE.BIN_DIR, 'pip')
|
LOCAL_PIP_DIRECTORY: join(CONFIG.STORAGE.BIN_DIR, 'pip'),
|
||||||
|
|
||||||
|
UPLOAD_IMAGES: join(CONFIG.STORAGE.UPLOADS_DIR, 'images')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS
|
export const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS
|
||||||
|
@ -1236,9 +1268,7 @@ export async function loadLanguages () {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const FILES_CONTENT_HASH = {
|
export const FILES_CONTENT_HASH = {
|
||||||
MANIFEST: generateContentHash(),
|
MANIFEST: generateContentHash()
|
||||||
FAVICON: generateContentHash(),
|
|
||||||
LOGO: generateContentHash()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { isTestOrDevInstance } from '@peertube/peertube-node-utils'
|
import { isTestOrDevInstance } from '@peertube/peertube-node-utils'
|
||||||
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js'
|
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js'
|
||||||
|
import { UploadImageModel } from '@server/models/application/upload-image.js'
|
||||||
import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
|
import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
|
||||||
import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js'
|
import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js'
|
||||||
import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js'
|
import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js'
|
||||||
|
@ -116,7 +117,6 @@ export function checkDatabaseConnectionOrDie () {
|
||||||
sequelizeTypescript.authenticate()
|
sequelizeTypescript.authenticate()
|
||||||
.then(() => logger.debug('Connection to PostgreSQL has been established successfully.'))
|
.then(() => logger.debug('Connection to PostgreSQL has been established successfully.'))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|
||||||
logger.error('Unable to connect to PostgreSQL database.', { err })
|
logger.error('Unable to connect to PostgreSQL database.', { err })
|
||||||
process.exit(-1)
|
process.exit(-1)
|
||||||
})
|
})
|
||||||
|
@ -186,7 +186,8 @@ export async function initDatabaseModels (silent: boolean) {
|
||||||
CommentAutomaticTagModel,
|
CommentAutomaticTagModel,
|
||||||
AutomaticTagModel,
|
AutomaticTagModel,
|
||||||
WatchedWordsListModel,
|
WatchedWordsListModel,
|
||||||
AccountAutomaticTagPolicyModel
|
AccountAutomaticTagPolicyModel,
|
||||||
|
UploadImageModel
|
||||||
])
|
])
|
||||||
|
|
||||||
// Check extensions exist in the database
|
// Check extensions exist in the database
|
||||||
|
@ -223,7 +224,6 @@ async function checkPostgresExtension (extension: string) {
|
||||||
// Try to create the extension ourselves
|
// Try to create the extension ourselves
|
||||||
try {
|
try {
|
||||||
await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true })
|
await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true })
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` +
|
const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` +
|
||||||
`You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.`
|
`You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.`
|
||||||
|
|
31
server/core/initializers/migrations/0900-uploads.ts
Normal file
31
server/core/initializers/migrations/0900-uploads.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
}): Promise<void> {
|
||||||
|
const query = `CREATE TABLE IF NOT EXISTS "uploadImage"(
|
||||||
|
"id" serial,
|
||||||
|
"filename" varchar(255) NOT NULL,
|
||||||
|
"height" integer DEFAULT NULL,
|
||||||
|
"width" integer DEFAULT NULL,
|
||||||
|
"fileUrl" varchar(255),
|
||||||
|
"type" integer 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, { transaction: utils.transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
up,
|
||||||
|
down
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ import { WEBSERVER } from '@server/initializers/constants.js'
|
||||||
import { AccountModel } from '@server/models/account/account.js'
|
import { AccountModel } from '@server/models/account/account.js'
|
||||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||||
import { MAccountHost, MChannelHost } from '@server/types/models/index.js'
|
import { MAccountDefault, MChannelDefault } from '@server/types/models/index.js'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { CONFIG } from '../../../initializers/config.js'
|
import { CONFIG } from '../../../initializers/config.js'
|
||||||
import { PageHtml } from './page-html.js'
|
import { PageHtml } from './page-html.js'
|
||||||
|
@ -55,8 +55,8 @@ export class ActorHtml {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private static async getAccountOrChannelHTMLPage (options: {
|
private static async getAccountOrChannelHTMLPage (options: {
|
||||||
loader: () => Promise<MAccountHost | MChannelHost>
|
loader: () => Promise<MAccountDefault | MChannelDefault>
|
||||||
getRSSFeeds: (entity: MAccountHost | MChannelHost) => TagsOptions['rssFeeds']
|
getRSSFeeds: (entity: MAccountDefault | MChannelDefault) => TagsOptions['rssFeeds']
|
||||||
req: express.Request
|
req: express.Request
|
||||||
res: express.Response
|
res: express.Response
|
||||||
}) {
|
}) {
|
||||||
|
|
|
@ -6,10 +6,9 @@ import {
|
||||||
is18nLocale,
|
is18nLocale,
|
||||||
POSSIBLE_LOCALES
|
POSSIBLE_LOCALES
|
||||||
} from '@peertube/peertube-core-utils'
|
} from '@peertube/peertube-core-utils'
|
||||||
import { ActorImageType, HTMLServerConfig } from '@peertube/peertube-models'
|
import { HTMLServerConfig } from '@peertube/peertube-models'
|
||||||
import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils'
|
import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils'
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
|
||||||
import { getServerActor } from '@server/models/application/application.js'
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { pathExists } from 'fs-extra/esm'
|
import { pathExists } from 'fs-extra/esm'
|
||||||
|
@ -32,7 +31,8 @@ export class PageHtml {
|
||||||
static async getDefaultHTML (req: express.Request, res: express.Response, paramLang?: string) {
|
static async getDefaultHTML (req: express.Request, res: express.Response, paramLang?: string) {
|
||||||
const html = await this.getIndexHTML(req, res, paramLang)
|
const html = await this.getIndexHTML(req, res, paramLang)
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
const avatar = serverActor.getMaxQualityImage(ActorImageType.AVATAR)
|
|
||||||
|
const openGraphImage = ServerConfigManager.Instance.getDefaultOpenGraph(serverActor)
|
||||||
|
|
||||||
let customHTML = TagsHtml.addTitleTag(html)
|
let customHTML = TagsHtml.addTitleTag(html)
|
||||||
customHTML = TagsHtml.addDescriptionTag(customHTML)
|
customHTML = TagsHtml.addDescriptionTag(customHTML)
|
||||||
|
@ -52,8 +52,8 @@ export class PageHtml {
|
||||||
? TagsHtml.findRelMe(CONFIG.INSTANCE.DESCRIPTION)
|
? TagsHtml.findRelMe(CONFIG.INSTANCE.DESCRIPTION)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
image: avatar
|
image: openGraphImage
|
||||||
? { url: ActorImageModel.getImageUrl(avatar), width: avatar.width, height: avatar.height }
|
? { url: openGraphImage.fileUrl, width: openGraphImage.width, height: openGraphImage.height }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
|
@ -99,8 +99,6 @@ export class PageHtml {
|
||||||
let html = buffer.toString()
|
let html = buffer.toString()
|
||||||
|
|
||||||
html = this.addManifestContentHash(html)
|
html = this.addManifestContentHash(html)
|
||||||
html = this.addFaviconContentHash(html)
|
|
||||||
html = this.addLogoContentHash(html)
|
|
||||||
|
|
||||||
html = this.addCustomCSS(html)
|
html = this.addCustomCSS(html)
|
||||||
html = this.addServerConfig(html, serverConfig)
|
html = this.addServerConfig(html, serverConfig)
|
||||||
|
@ -189,12 +187,4 @@ export class PageHtml {
|
||||||
private static addManifestContentHash (htmlStringPage: string) {
|
private static addManifestContentHash (htmlStringPage: string) {
|
||||||
return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
|
return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static addFaviconContentHash (htmlStringPage: string) {
|
|
||||||
return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addLogoContentHash (htmlStringPage: string) {
|
|
||||||
return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { escapeAttribute, escapeHTML } from '@peertube/peertube-core-utils'
|
import { escapeAttribute, escapeHTML, findAppropriateImage } from '@peertube/peertube-core-utils'
|
||||||
import { mdToPlainText } from '@server/helpers/markdown.js'
|
import { mdToPlainText } from '@server/helpers/markdown.js'
|
||||||
|
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
|
||||||
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
import truncate from 'lodash-es/truncate.js'
|
import truncate from 'lodash-es/truncate.js'
|
||||||
import { parse } from 'node-html-parser'
|
import { parse } from 'node-html-parser'
|
||||||
import { CONFIG } from '../../../initializers/config.js'
|
import { CONFIG } from '../../../initializers/config.js'
|
||||||
|
@ -87,26 +89,17 @@ export class TagsHtml {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static async addTags (htmlStringPage: string, tagsValues: TagsOptions, context: HookContext) {
|
static async addTags (htmlStringPage: string, tagsValues: TagsOptions, context: HookContext) {
|
||||||
|
const { url, escapedTitle, oembedUrl, forbidIndexation, embedIndexation, relMe, rssFeeds } = tagsValues
|
||||||
|
const serverActor = await getServerActor()
|
||||||
|
|
||||||
|
let tagsStr = ''
|
||||||
|
|
||||||
|
// Global meta tags
|
||||||
const metaTags = {
|
const metaTags = {
|
||||||
...this.generateOpenGraphMetaTagsOptions(tagsValues),
|
...this.generateOpenGraphMetaTagsOptions(tagsValues),
|
||||||
...this.generateStandardMetaTagsOptions(tagsValues),
|
...this.generateStandardMetaTagsOptions(tagsValues),
|
||||||
...this.generateTwitterCardMetaTagsOptions(tagsValues)
|
...this.generateTwitterCardMetaTagsOptions(tagsValues)
|
||||||
}
|
}
|
||||||
const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context)
|
|
||||||
|
|
||||||
const { url, escapedTitle, oembedUrl, forbidIndexation, embedIndexation, relMe, rssFeeds } = tagsValues
|
|
||||||
|
|
||||||
const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
|
|
||||||
|
|
||||||
if (oembedUrl) {
|
|
||||||
oembedLinkTags.push({
|
|
||||||
type: 'application/json+oembed',
|
|
||||||
href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(oembedUrl),
|
|
||||||
escapedTitle
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let tagsStr = ''
|
|
||||||
|
|
||||||
for (const tagName of Object.keys(metaTags)) {
|
for (const tagName of Object.keys(metaTags)) {
|
||||||
const tagValue = metaTags[tagName]
|
const tagValue = metaTags[tagName]
|
||||||
|
@ -116,23 +109,27 @@ export class TagsHtml {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OEmbed
|
// OEmbed
|
||||||
for (const oembedLinkTag of oembedLinkTags) {
|
if (oembedUrl) {
|
||||||
tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${
|
const href = WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(oembedUrl)
|
||||||
escapeAttribute(oembedLinkTag.escapedTitle)
|
|
||||||
}" />`
|
tagsStr += `<link rel="alternate" type="application/json+oembed" href="${href}" title="${escapeAttribute(escapedTitle)}" />`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schema.org
|
// Schema.org
|
||||||
|
const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context)
|
||||||
|
|
||||||
if (schemaTags) {
|
if (schemaTags) {
|
||||||
tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
|
tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rel Me
|
||||||
if (Array.isArray(relMe)) {
|
if (Array.isArray(relMe)) {
|
||||||
for (const relMeLink of relMe) {
|
for (const relMeLink of relMe) {
|
||||||
tagsStr += `<link href="${escapeAttribute(relMeLink)}" rel="me">`
|
tagsStr += `<link href="${escapeAttribute(relMeLink)}" rel="me">`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEO
|
||||||
if (forbidIndexation === true) {
|
if (forbidIndexation === true) {
|
||||||
tagsStr += `<meta name="robots" content="noindex" />`
|
tagsStr += `<meta name="robots" content="noindex" />`
|
||||||
} else if (embedIndexation) {
|
} else if (embedIndexation) {
|
||||||
|
@ -141,10 +138,23 @@ export class TagsHtml {
|
||||||
tagsStr += `<link rel="canonical" href="${url}" />`
|
tagsStr += `<link rel="canonical" href="${url}" />`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RSS
|
||||||
for (const rssLink of (rssFeeds || [])) {
|
for (const rssLink of (rssFeeds || [])) {
|
||||||
tagsStr += `<link rel="alternate" type="application/rss+xml" title="${escapeAttribute(rssLink.title)}" href="${rssLink.url}" />`
|
tagsStr += `<link rel="alternate" type="application/rss+xml" title="${escapeAttribute(rssLink.title)}" href="${rssLink.url}" />`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Favicon
|
||||||
|
const favicon = ServerConfigManager.Instance.getFavicon(serverActor)
|
||||||
|
tagsStr += `<link rel="icon" type="image/png" href="${escapeAttribute(favicon.fileUrl)}" />`
|
||||||
|
|
||||||
|
// Apple Touch Icon
|
||||||
|
const appleTouchIcon = findAppropriateImage(serverActor.Avatars, 192)
|
||||||
|
const iconHref = appleTouchIcon
|
||||||
|
? WEBSERVER.URL + appleTouchIcon.getStaticPath()
|
||||||
|
: '/client/assets/images/icons/icon-192x192.png'
|
||||||
|
|
||||||
|
tagsStr += `<link rel="apple-touch-icon" href="${iconHref}" />`
|
||||||
|
|
||||||
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
|
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { findAppropriateImage, maxBy } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
|
ActorImageType,
|
||||||
HTMLServerConfig,
|
HTMLServerConfig,
|
||||||
|
LogoType,
|
||||||
RegisteredExternalAuthConfig,
|
RegisteredExternalAuthConfig,
|
||||||
RegisteredIdAndPassAuthConfig,
|
RegisteredIdAndPassAuthConfig,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
|
@ -8,15 +11,19 @@ import {
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { getServerCommit } from '@server/helpers/version.js'
|
import { getServerCommit } from '@server/helpers/version.js'
|
||||||
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
|
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
|
||||||
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants.js'
|
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, WEBSERVER } from '@server/initializers/constants.js'
|
||||||
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/lib/signup.js'
|
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/lib/signup.js'
|
||||||
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js'
|
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js'
|
||||||
|
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||||
import { getServerActor } from '@server/models/application/application.js'
|
import { getServerActor } from '@server/models/application/application.js'
|
||||||
|
import { UploadImageModel } from '@server/models/application/upload-image.js'
|
||||||
import { PluginModel } from '@server/models/server/plugin.js'
|
import { PluginModel } from '@server/models/server/plugin.js'
|
||||||
|
import { MActorImage, MActorUploadImages, MUploadImage } from '@server/types/models/index.js'
|
||||||
import { Hooks } from './plugins/hooks.js'
|
import { Hooks } from './plugins/hooks.js'
|
||||||
import { PluginManager } from './plugins/plugin-manager.js'
|
import { PluginManager } from './plugins/plugin-manager.js'
|
||||||
import { getThemeOrDefault } from './plugins/theme-utils.js'
|
import { getThemeOrDefault } from './plugins/theme-utils.js'
|
||||||
import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles.js'
|
import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles.js'
|
||||||
|
import { logoTypeToUploadImageEnum } from './upload-image.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to send the server config to clients (using REST/API or plugins API)
|
* Used to send the server config to clients (using REST/API or plugins API)
|
||||||
|
@ -51,6 +58,9 @@ class ServerConfigManager {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client: {
|
client: {
|
||||||
|
header: {
|
||||||
|
hideInstanceName: CONFIG.CLIENT.HEADER.HIDE_INSTANCE_NAME
|
||||||
|
},
|
||||||
videos: {
|
videos: {
|
||||||
miniature: {
|
miniature: {
|
||||||
preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
|
preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
|
||||||
|
@ -133,8 +143,16 @@ class ServerConfigManager {
|
||||||
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
|
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
|
||||||
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
|
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
|
||||||
},
|
},
|
||||||
|
|
||||||
avatars: serverActor.Avatars.map(a => a.toFormattedJSON()),
|
avatars: serverActor.Avatars.map(a => a.toFormattedJSON()),
|
||||||
banners: serverActor.Banners.map(b => b.toFormattedJSON())
|
banners: serverActor.Banners.map(b => b.toFormattedJSON()),
|
||||||
|
|
||||||
|
logo: [
|
||||||
|
...this.getFaviconLogos(serverActor),
|
||||||
|
...this.getMobileHeaderLogos(serverActor),
|
||||||
|
...this.getDesktopHeaderLogos(serverActor),
|
||||||
|
...this.getOpenGraphLogos(serverActor)
|
||||||
|
]
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
remoteUri: {
|
remoteUri: {
|
||||||
|
@ -482,6 +500,117 @@ class ServerConfigManager {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getFavicon (serverActor: MActorUploadImages) {
|
||||||
|
return findAppropriateImage(this.getFaviconLogos(serverActor), 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultOpenGraph (serverActor: MActorUploadImages) {
|
||||||
|
return maxBy(this.getOpenGraphLogos(serverActor), 'width')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFaviconLogos (serverActor: MActorUploadImages) {
|
||||||
|
return this.getLogoWithFallbacks({
|
||||||
|
serverActor,
|
||||||
|
logoType: 'favicon',
|
||||||
|
|
||||||
|
defaultLogo: {
|
||||||
|
fileUrl: WEBSERVER.URL + '/client/assets/images/favicon.png',
|
||||||
|
width: 32,
|
||||||
|
height: 32
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMobileHeaderLogos (serverActor: MActorUploadImages) {
|
||||||
|
return this.getLogoWithFallbacks({
|
||||||
|
serverActor,
|
||||||
|
logoType: 'header-square',
|
||||||
|
|
||||||
|
defaultLogo: {
|
||||||
|
fileUrl: WEBSERVER.URL + '/client/assets/images/logo.svg',
|
||||||
|
width: 34,
|
||||||
|
height: 34
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDesktopHeaderLogos (serverActor: MActorUploadImages) {
|
||||||
|
return this.getLogoWithFallbacks({
|
||||||
|
serverActor,
|
||||||
|
logoType: 'header-wide',
|
||||||
|
|
||||||
|
defaultLogo: {
|
||||||
|
fileUrl: WEBSERVER.URL + '/client/assets/images/logo.svg',
|
||||||
|
width: 34,
|
||||||
|
height: 34
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOpenGraphLogos (serverActor: MActorUploadImages) {
|
||||||
|
return this.getLogoWithFallbacks({
|
||||||
|
serverActor,
|
||||||
|
logoType: 'opengraph',
|
||||||
|
|
||||||
|
defaultLogo: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLogoWithFallbacks (options: {
|
||||||
|
serverActor: MActorUploadImages
|
||||||
|
logoType: LogoType
|
||||||
|
|
||||||
|
defaultLogo: {
|
||||||
|
fileUrl: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const { serverActor, logoType, defaultLogo } = options
|
||||||
|
|
||||||
|
const uploadImageType = logoTypeToUploadImageEnum(logoType)
|
||||||
|
|
||||||
|
const uploaded = serverActor.UploadImages
|
||||||
|
.filter(i => i.type === uploadImageType)
|
||||||
|
.map(i => this.formatUploadImageForLogo(i, logoType, false))
|
||||||
|
|
||||||
|
if (uploaded.length !== 0) return uploaded
|
||||||
|
|
||||||
|
// Avatar fallback?
|
||||||
|
if (serverActor.hasImage(ActorImageType.AVATAR)) {
|
||||||
|
return serverActor.Avatars.map(a => this.formatActorImageForLogo(a, logoType, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default mobile header logo?
|
||||||
|
if (!defaultLogo) return []
|
||||||
|
|
||||||
|
return [ { ...defaultLogo, type: logoType, isFallback: true } ]
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUploadImageForLogo (logo: MUploadImage, type: LogoType, isFallback: boolean) {
|
||||||
|
return {
|
||||||
|
height: logo.height,
|
||||||
|
width: logo.width,
|
||||||
|
type,
|
||||||
|
fileUrl: UploadImageModel.getImageUrl(logo),
|
||||||
|
isFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatActorImageForLogo (logo: MActorImage, type: LogoType, isFallback: boolean) {
|
||||||
|
return {
|
||||||
|
height: logo.height,
|
||||||
|
width: logo.width,
|
||||||
|
type,
|
||||||
|
fileUrl: ActorImageModel.getImageUrl(logo),
|
||||||
|
isFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static get Instance () {
|
static get Instance () {
|
||||||
return this.instance || (this.instance = new this())
|
return this.instance || (this.instance = new this())
|
||||||
}
|
}
|
||||||
|
|
96
server/core/lib/upload-image.ts
Normal file
96
server/core/lib/upload-image.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { LogoType, UploadImageType, UploadImageType_Type } from '@peertube/peertube-models'
|
||||||
|
import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils'
|
||||||
|
import { buildImageSize } from '@server/helpers/image-utils.js'
|
||||||
|
import { UploadImageModel } from '@server/models/application/upload-image.js'
|
||||||
|
import { remove } from 'fs-extra/esm'
|
||||||
|
import { retryTransactionWrapper } from '../helpers/database-utils.js'
|
||||||
|
import { UPLOAD_IMAGES_SIZE } from '../initializers/constants.js'
|
||||||
|
import { sequelizeTypescript } from '../initializers/database.js'
|
||||||
|
import { MActorUploadImages } from '../types/models/index.js'
|
||||||
|
import { processImageFromWorker } from './worker/parent-process.js'
|
||||||
|
|
||||||
|
export async function replaceUploadImage (options: {
|
||||||
|
actor: MActorUploadImages
|
||||||
|
imagePhysicalFile: { path: string }
|
||||||
|
type: UploadImageType_Type
|
||||||
|
}) {
|
||||||
|
const { actor, imagePhysicalFile, type } = options
|
||||||
|
|
||||||
|
const processImageSize = async (imageSizeArg: { width: number, height: number }) => {
|
||||||
|
const imageSize = await buildImageSize(imagePhysicalFile.path, imageSizeArg)
|
||||||
|
|
||||||
|
const extension = getLowercaseExtension(imagePhysicalFile.path)
|
||||||
|
const imageName = buildUUID() + extension
|
||||||
|
const destination = UploadImageModel.getPathOf(imageName)
|
||||||
|
|
||||||
|
await processImageFromWorker({ path: imagePhysicalFile.path, destination, newSize: imageSize, keepOriginal: true })
|
||||||
|
|
||||||
|
return { imageName, imageSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedImages = await Promise.all(UPLOAD_IMAGES_SIZE[type].map(processImageSize))
|
||||||
|
await remove(imagePhysicalFile.path)
|
||||||
|
|
||||||
|
return retryTransactionWrapper(() =>
|
||||||
|
sequelizeTypescript.transaction(async t => {
|
||||||
|
const imagesToDelete = await UploadImageModel.listByActorAndType(actor, type, t)
|
||||||
|
|
||||||
|
for (const toDelete of imagesToDelete) {
|
||||||
|
await toDelete.destroy({ transaction: t })
|
||||||
|
|
||||||
|
actor.UploadImages = actor.UploadImages.filter(image => image.id !== toDelete.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toCreate of processedImages) {
|
||||||
|
const uploadImage = await UploadImageModel.create({
|
||||||
|
filename: toCreate.imageName,
|
||||||
|
height: toCreate.imageSize.height,
|
||||||
|
width: toCreate.imageSize.width,
|
||||||
|
fileUrl: null,
|
||||||
|
type,
|
||||||
|
actorId: actor.id
|
||||||
|
}, { transaction: t })
|
||||||
|
|
||||||
|
actor.UploadImages.push(uploadImage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUploadImages (options: {
|
||||||
|
actor: MActorUploadImages
|
||||||
|
type: UploadImageType_Type
|
||||||
|
}) {
|
||||||
|
const { actor, type } = options
|
||||||
|
|
||||||
|
return retryTransactionWrapper(() => {
|
||||||
|
return sequelizeTypescript.transaction(async t => {
|
||||||
|
const imagesToDelete = await UploadImageModel.listByActorAndType(actor, type, t)
|
||||||
|
|
||||||
|
for (const toDelete of imagesToDelete) {
|
||||||
|
await toDelete.destroy({ transaction: t })
|
||||||
|
}
|
||||||
|
|
||||||
|
actor.UploadImages = []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoTypeToUploadImageEnum (logoType: LogoType) {
|
||||||
|
switch (logoType) {
|
||||||
|
case 'favicon':
|
||||||
|
return UploadImageType.INSTANCE_FAVICON
|
||||||
|
|
||||||
|
case 'header-wide':
|
||||||
|
return UploadImageType.INSTANCE_HEADER_WIDE
|
||||||
|
|
||||||
|
case 'header-square':
|
||||||
|
return UploadImageType.INSTANCE_HEADER_SQUARE
|
||||||
|
|
||||||
|
case 'opengraph':
|
||||||
|
return UploadImageType.INSTANCE_OPENGRAPH
|
||||||
|
|
||||||
|
default:
|
||||||
|
return logoType satisfies never
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,4 @@
|
||||||
import express from 'express'
|
import { updateActorImageValidatorFactory } from './shared/images.js'
|
||||||
import { body } from 'express-validator'
|
|
||||||
import { isActorImageFile } from '@server/helpers/custom-validators/actor-images.js'
|
|
||||||
import { cleanUpReqFiles } from '../../helpers/express-utils.js'
|
|
||||||
import { CONSTRAINTS_FIELDS } from '../../initializers/constants.js'
|
|
||||||
import { areValidationErrors } from './shared/index.js'
|
|
||||||
|
|
||||||
const updateActorImageValidatorFactory = (fieldname: string) => ([
|
|
||||||
body(fieldname).custom((value, { req }) => isActorImageFile(req.files, fieldname)).withMessage(
|
|
||||||
'This file is not supported or too large. Please, make sure it is of the following type : ' +
|
|
||||||
CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME.join(', ')
|
|
||||||
),
|
|
||||||
|
|
||||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
||||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
|
||||||
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
export const updateAvatarValidator = updateActorImageValidatorFactory('avatarfile')
|
export const updateAvatarValidator = updateActorImageValidatorFactory('avatarfile')
|
||||||
export const updateBannerValidator = updateActorImageValidatorFactory('bannerfile')
|
export const updateBannerValidator = updateActorImageValidatorFactory('bannerfile')
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import express from 'express'
|
|
||||||
import { body } from 'express-validator'
|
|
||||||
import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
|
import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
|
||||||
|
import { isConfigLogoTypeValid } from '@server/helpers/custom-validators/config.js'
|
||||||
import { isIntOrNull } from '@server/helpers/custom-validators/misc.js'
|
import { isIntOrNull } from '@server/helpers/custom-validators/misc.js'
|
||||||
|
import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js'
|
||||||
|
import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.js'
|
||||||
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
|
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
|
||||||
|
import express from 'express'
|
||||||
|
import { body, param } from 'express-validator'
|
||||||
import { isThemeNameValid } from '../../helpers/custom-validators/plugins.js'
|
import { isThemeNameValid } from '../../helpers/custom-validators/plugins.js'
|
||||||
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js'
|
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js'
|
||||||
import { isThemeRegistered } from '../../lib/plugins/theme-utils.js'
|
import { isThemeRegistered } from '../../lib/plugins/theme-utils.js'
|
||||||
import { areValidationErrors } from './shared/index.js'
|
import { areValidationErrors, updateActorImageValidatorFactory } from './shared/index.js'
|
||||||
import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js'
|
|
||||||
import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.js'
|
|
||||||
|
|
||||||
const customConfigUpdateValidator = [
|
export const customConfigUpdateValidator = [
|
||||||
body('instance.name').exists(),
|
body('instance.name').exists(),
|
||||||
body('instance.shortDescription').exists(),
|
body('instance.shortDescription').exists(),
|
||||||
body('instance.description').exists(),
|
body('instance.description').exists(),
|
||||||
|
@ -161,7 +162,7 @@ const customConfigUpdateValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
function ensureConfigIsEditable (req: express.Request, res: express.Response, next: express.NextFunction) {
|
export function ensureConfigIsEditable (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
if (!CONFIG.WEBADMIN.CONFIGURATION.EDITION.ALLOWED) {
|
if (!CONFIG.WEBADMIN.CONFIGURATION.EDITION.ALLOWED) {
|
||||||
return res.fail({
|
return res.fail({
|
||||||
status: HttpStatusCode.METHOD_NOT_ALLOWED_405,
|
status: HttpStatusCode.METHOD_NOT_ALLOWED_405,
|
||||||
|
@ -172,12 +173,22 @@ function ensureConfigIsEditable (req: express.Request, res: express.Response, ne
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export const updateOrDeleteLogoValidator = [
|
||||||
|
param('logoType')
|
||||||
|
.custom(isConfigLogoTypeValid),
|
||||||
|
|
||||||
export {
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
customConfigUpdateValidator,
|
if (areValidationErrors(req, res)) return
|
||||||
ensureConfigIsEditable
|
|
||||||
}
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const updateInstanceLogoValidator = updateActorImageValidatorFactory('logofile')
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function checkInvalidConfigIfEmailDisabled (customConfig: CustomConfig, res: express.Response) {
|
function checkInvalidConfigIfEmailDisabled (customConfig: CustomConfig, res: express.Response) {
|
||||||
if (isEmailEnabled()) return true
|
if (isEmailEnabled()) return true
|
||||||
|
|
19
server/core/middlewares/validators/shared/images.ts
Normal file
19
server/core/middlewares/validators/shared/images.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { isActorImageFile } from '@server/helpers/custom-validators/actor-images.js'
|
||||||
|
import { cleanUpReqFiles } from '@server/helpers/express-utils.js'
|
||||||
|
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
|
||||||
|
import express from 'express'
|
||||||
|
import { body } from 'express-validator'
|
||||||
|
import { areValidationErrors } from './utils.js'
|
||||||
|
|
||||||
|
export const updateActorImageValidatorFactory = (fieldname: string) => [
|
||||||
|
body(fieldname).custom((value, { req }) => isActorImageFile(req.files, fieldname)).withMessage(
|
||||||
|
'This file is not supported or too large. Please, make sure it is of the following type : ' +
|
||||||
|
CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME.join(', ')
|
||||||
|
),
|
||||||
|
|
||||||
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './abuses.js'
|
export * from './abuses.js'
|
||||||
export * from './accounts.js'
|
export * from './accounts.js'
|
||||||
|
export * from './images.js'
|
||||||
export * from './users.js'
|
export * from './users.js'
|
||||||
export * from './utils.js'
|
export * from './utils.js'
|
||||||
export * from './video-blacklists.js'
|
export * from './video-blacklists.js'
|
||||||
|
|
|
@ -4,16 +4,7 @@ import { MActorId, MActorImage, MActorImageFormattable, MActorImagePath } from '
|
||||||
import { remove } from 'fs-extra/esm'
|
import { remove } from 'fs-extra/esm'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { Op } from 'sequelize'
|
import { Op } from 'sequelize'
|
||||||
import {
|
import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||||
AfterDestroy,
|
|
||||||
AllowNull,
|
|
||||||
BelongsTo,
|
|
||||||
Column,
|
|
||||||
CreatedAt,
|
|
||||||
Default,
|
|
||||||
ForeignKey, Table,
|
|
||||||
UpdatedAt
|
|
||||||
} from 'sequelize-typescript'
|
|
||||||
import { logger } from '../../helpers/logger.js'
|
import { logger } from '../../helpers/logger.js'
|
||||||
import { CONFIG } from '../../initializers/config.js'
|
import { CONFIG } from '../../initializers/config.js'
|
||||||
import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants.js'
|
import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants.js'
|
||||||
|
@ -34,7 +25,6 @@ import { ActorModel } from './actor.js'
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ActorImageModel extends SequelizeModel<ActorImageModel> {
|
export class ActorImageModel extends SequelizeModel<ActorImageModel> {
|
||||||
|
|
||||||
@AllowNull(false)
|
@AllowNull(false)
|
||||||
@Column
|
@Column
|
||||||
filename: string
|
filename: string
|
||||||
|
@ -159,6 +149,7 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
|
||||||
|
|
||||||
toFormattedJSON (this: MActorImageFormattable): ActorImage {
|
toFormattedJSON (this: MActorImageFormattable): ActorImage {
|
||||||
return {
|
return {
|
||||||
|
height: this.height,
|
||||||
width: this.width,
|
width: this.width,
|
||||||
path: this.getStaticPath(),
|
path: this.getStaticPath(),
|
||||||
fileUrl: ActorImageModel.getImageUrl(this),
|
fileUrl: ActorImageModel.getImageUrl(this),
|
||||||
|
@ -168,11 +159,9 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
toActivityPubObject (): ActivityIconObject {
|
toActivityPubObject (): ActivityIconObject {
|
||||||
const extension = getLowercaseExtension(this.filename)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'Image',
|
type: 'Image',
|
||||||
mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
|
mediaType: this.getMimeType(),
|
||||||
height: this.height,
|
height: this.height,
|
||||||
width: this.width,
|
width: this.width,
|
||||||
url: ActorImageModel.getImageUrl(this)
|
url: ActorImageModel.getImageUrl(this)
|
||||||
|
@ -204,4 +193,8 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
|
||||||
isOwned () {
|
isOwned () {
|
||||||
return !this.fileUrl
|
return !this.fileUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMimeType () {
|
||||||
|
return MIMETYPES.IMAGE.EXT_MIMETYPE[getLowercaseExtension(this.filename)]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
|
import { findAppropriateImage, forceNumber, maxBy } from '@peertube/peertube-core-utils'
|
||||||
import { ActivityIconObject, ActorImageType, ActorImageType_Type, type ActivityPubActorType } from '@peertube/peertube-models'
|
import { ActivityIconObject, ActorImageType, ActorImageType_Type, type ActivityPubActorType } from '@peertube/peertube-models'
|
||||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||||
import { activityPubContextify } from '@server/helpers/activity-pub-utils.js'
|
import { activityPubContextify } from '@server/helpers/activity-pub-utils.js'
|
||||||
|
@ -47,6 +47,7 @@ import {
|
||||||
} from '../../types/models/index.js'
|
} from '../../types/models/index.js'
|
||||||
import { AccountModel } from '../account/account.js'
|
import { AccountModel } from '../account/account.js'
|
||||||
import { getServerActor } from '../application/application.js'
|
import { getServerActor } from '../application/application.js'
|
||||||
|
import { UploadImageModel } from '../application/upload-image.js'
|
||||||
import { ServerModel } from '../server/server.js'
|
import { ServerModel } from '../server/server.js'
|
||||||
import { SequelizeModel, buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared/index.js'
|
import { SequelizeModel, buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared/index.js'
|
||||||
import { VideoChannelModel } from '../video/video-channel.js'
|
import { VideoChannelModel } from '../video/video-channel.js'
|
||||||
|
@ -250,6 +251,16 @@ export class ActorModel extends SequelizeModel<ActorModel> {
|
||||||
})
|
})
|
||||||
Banners: Awaited<ActorImageModel>[]
|
Banners: Awaited<ActorImageModel>[]
|
||||||
|
|
||||||
|
@HasMany(() => UploadImageModel, {
|
||||||
|
as: 'UploadImages',
|
||||||
|
onDelete: 'cascade',
|
||||||
|
hooks: true,
|
||||||
|
foreignKey: {
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
UploadImages: Awaited<UploadImageModel>[]
|
||||||
|
|
||||||
@HasMany(() => ActorFollowModel, {
|
@HasMany(() => ActorFollowModel, {
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'actorId',
|
name: 'actorId',
|
||||||
|
@ -686,6 +697,16 @@ export class ActorModel extends SequelizeModel<ActorModel> {
|
||||||
return maxBy(images, 'height')
|
return maxBy(images, 'height')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAppropriateQualityImage (type: ActorImageType_Type, width: number) {
|
||||||
|
if (!this.hasImage(type)) return undefined
|
||||||
|
|
||||||
|
const images = type === ActorImageType.AVATAR
|
||||||
|
? this.Avatars
|
||||||
|
: this.Banners
|
||||||
|
|
||||||
|
return findAppropriateImage(images, width)
|
||||||
|
}
|
||||||
|
|
||||||
isOutdated () {
|
isOutdated () {
|
||||||
if (this.isOwned()) return false
|
if (this.isOwned()) return false
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Table } from '
|
||||||
import { AccountModel } from '../account/account.js'
|
import { AccountModel } from '../account/account.js'
|
||||||
import { ActorImageModel } from '../actor/actor-image.js'
|
import { ActorImageModel } from '../actor/actor-image.js'
|
||||||
import { SequelizeModel } from '../shared/index.js'
|
import { SequelizeModel } from '../shared/index.js'
|
||||||
|
import { UploadImageModel } from './upload-image.js'
|
||||||
|
|
||||||
export const getServerActor = memoizee(async function () {
|
export const getServerActor = memoizee(async function () {
|
||||||
const application = await ApplicationModel.load()
|
const application = await ApplicationModel.load()
|
||||||
|
@ -16,6 +17,9 @@ export const getServerActor = memoizee(async function () {
|
||||||
actor.Avatars = avatars
|
actor.Avatars = avatars
|
||||||
actor.Banners = banners
|
actor.Banners = banners
|
||||||
|
|
||||||
|
const uploadImages = await UploadImageModel.listByActor(actor)
|
||||||
|
actor.UploadImages = uploadImages
|
||||||
|
|
||||||
return actor
|
return actor
|
||||||
}, { promise: true })
|
}, { promise: true })
|
||||||
|
|
||||||
|
@ -32,7 +36,6 @@ export const getServerActor = memoizee(async function () {
|
||||||
timestamps: false
|
timestamps: false
|
||||||
})
|
})
|
||||||
export class ApplicationModel extends SequelizeModel<ApplicationModel> {
|
export class ApplicationModel extends SequelizeModel<ApplicationModel> {
|
||||||
|
|
||||||
@AllowNull(false)
|
@AllowNull(false)
|
||||||
@Default(0)
|
@Default(0)
|
||||||
@IsInt
|
@IsInt
|
||||||
|
|
127
server/core/models/application/upload-image.ts
Normal file
127
server/core/models/application/upload-image.ts
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
import { type UploadImageType_Type } from '@peertube/peertube-models'
|
||||||
|
import { MActorId, MUploadImage } from '@server/types/models/index.js'
|
||||||
|
import { remove } from 'fs-extra/esm'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { Transaction } from 'sequelize'
|
||||||
|
import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||||
|
import { logger } from '../../helpers/logger.js'
|
||||||
|
import { DIRECTORIES, STATIC_PATHS, WEBSERVER } from '../../initializers/constants.js'
|
||||||
|
import { ActorModel } from '../actor/actor.js'
|
||||||
|
import { SequelizeModel } from '../shared/index.js'
|
||||||
|
|
||||||
|
// Image uploads that are not suitable for other tables actor images (avatars/banners)
|
||||||
|
// Can be used to store instance images like logos, favicons, etc.
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 'uploadImage',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: [ 'filename' ],
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: [ 'actorId', 'type', 'width' ],
|
||||||
|
unique: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class UploadImageModel extends SequelizeModel<UploadImageModel> {
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
filename: string
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Default(null)
|
||||||
|
@Column
|
||||||
|
height: number
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Default(null)
|
||||||
|
@Column
|
||||||
|
width: number
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
fileUrl: string
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
type: UploadImageType_Type
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
createdAt: Date
|
||||||
|
|
||||||
|
@UpdatedAt
|
||||||
|
updatedAt: Date
|
||||||
|
|
||||||
|
@ForeignKey(() => ActorModel)
|
||||||
|
@Column
|
||||||
|
actorId: number
|
||||||
|
|
||||||
|
@BelongsTo(() => ActorModel, {
|
||||||
|
foreignKey: {
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
Actor: Awaited<ActorModel>
|
||||||
|
|
||||||
|
@AfterDestroy
|
||||||
|
static removeFile (instance: UploadImageModel) {
|
||||||
|
logger.info('Removing upload image file %s.', instance.filename)
|
||||||
|
|
||||||
|
// Don't block the transaction
|
||||||
|
instance.removeImage()
|
||||||
|
.catch(err => logger.error('Cannot remove upload image file %s.', instance.filename, { err }))
|
||||||
|
}
|
||||||
|
|
||||||
|
static listByActor (actor: MActorId) {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
actorId: actor.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UploadImageModel.findAll(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
static listByActorAndType (actor: MActorId, type: UploadImageType_Type, transaction: Transaction) {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
actorId: actor.id,
|
||||||
|
type
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
return UploadImageModel.findAll(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getImageUrl (image: MUploadImage) {
|
||||||
|
if (!image) return undefined
|
||||||
|
|
||||||
|
return WEBSERVER.URL + image.getStaticPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPathOf (filename: string) {
|
||||||
|
return join(DIRECTORIES.UPLOAD_IMAGES, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getStaticPath (this: MUploadImage) {
|
||||||
|
return join(STATIC_PATHS.UPLOAD_IMAGES, this.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPath () {
|
||||||
|
return UploadImageModel.getPathOf(this.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeImage () {
|
||||||
|
return remove(this.getPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwned () {
|
||||||
|
return !this.fileUrl
|
||||||
|
}
|
||||||
|
}
|
|
@ -385,7 +385,7 @@ export class UserModel extends SequelizeModel<UserModel> {
|
||||||
@Default(UserAdminFlag.NONE)
|
@Default(UserAdminFlag.NONE)
|
||||||
@Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
|
@Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
|
||||||
@Column
|
@Column
|
||||||
adminFlags?: UserAdminFlagType
|
adminFlags: UserAdminFlagType
|
||||||
|
|
||||||
@AllowNull(false)
|
@AllowNull(false)
|
||||||
@Default(false)
|
@Default(false)
|
||||||
|
|
|
@ -11,4 +11,4 @@ export type MActorImagePath = Pick<MActorImage, 'type' | 'filename' | 'getStatic
|
||||||
|
|
||||||
export type MActorImageFormattable =
|
export type MActorImageFormattable =
|
||||||
& FunctionProperties<MActorImage>
|
& FunctionProperties<MActorImage>
|
||||||
& Pick<MActorImage, 'type' | 'getStaticPath' | 'width' | 'filename' | 'createdAt' | 'updatedAt'>
|
& Pick<MActorImage, 'type' | 'getStaticPath' | 'width' | 'height' | 'filename' | 'createdAt' | 'updatedAt'>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { FunctionProperties, PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils'
|
import { FunctionProperties, PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils'
|
||||||
import { ActorModel } from '../../../models/actor/actor.js'
|
import { ActorModel } from '../../../models/actor/actor.js'
|
||||||
import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from '../account/index.js'
|
import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from '../account/index.js'
|
||||||
|
import { MUploadImage } from '../application/upload-image.js'
|
||||||
import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server/index.js'
|
import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server/index.js'
|
||||||
import { MChannel, MChannelAccountActor, MChannelAccountDefault, MChannelId, MChannelIdActor } from '../video/index.js'
|
import { MChannel, MChannelAccountActor, MChannelAccountDefault, MChannelId, MChannelIdActor } from '../video/index.js'
|
||||||
import { MActorImage, MActorImageFormattable } from './actor-image.js'
|
import { MActorImage, MActorImageFormattable } from './actor-image.js'
|
||||||
|
@ -10,7 +11,10 @@ type UseOpt<K extends keyof ActorModel, M> = PickWithOpt<ActorModel, K, M>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
export type MActor = Omit<ActorModel, 'Account' | 'VideoChannel' | 'ActorFollowing' | 'ActorFollowers' | 'Server' | 'Banners'>
|
export type MActor = Omit<
|
||||||
|
ActorModel,
|
||||||
|
'Account' | 'VideoChannel' | 'ActorFollowing' | 'ActorFollowers' | 'Server' | 'Banners' | 'Avatars' | 'UploadImages'
|
||||||
|
>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
|
@ -61,6 +65,8 @@ export type MActorChannelIdActor =
|
||||||
export type MActorAccountChannelId = MActorAccountId & MActorChannelId
|
export type MActorAccountChannelId = MActorAccountId & MActorChannelId
|
||||||
export type MActorAccountChannelIdActor = MActorAccountIdActor & MActorChannelIdActor
|
export type MActorAccountChannelIdActor = MActorAccountIdActor & MActorChannelIdActor
|
||||||
|
|
||||||
|
export type MActorUploadImages = MActorImages & Use<'UploadImages', MUploadImage[]>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
// Include raw account/channel/server
|
// Include raw account/channel/server
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from './application.js'
|
export * from './application.js'
|
||||||
|
export * from './upload-image.js'
|
||||||
|
|
3
server/core/types/models/application/upload-image.ts
Normal file
3
server/core/types/models/application/upload-image.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { UploadImageModel } from '@server/models/application/upload-image.js'
|
||||||
|
|
||||||
|
export type MUploadImage = UploadImageModel
|
|
@ -1076,6 +1076,55 @@ paths:
|
||||||
'204':
|
'204':
|
||||||
description: successful operation
|
description: successful operation
|
||||||
|
|
||||||
|
'/api/v1/config/instance-logo/:logoType/pick':
|
||||||
|
post:
|
||||||
|
summary: Update instance logo
|
||||||
|
security:
|
||||||
|
- OAuth2:
|
||||||
|
- admin
|
||||||
|
tags:
|
||||||
|
- Config
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/logoTypeParam'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: successful operation
|
||||||
|
'413':
|
||||||
|
description: image file too large
|
||||||
|
headers:
|
||||||
|
X-File-Maximum-Size:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: Nginx size
|
||||||
|
description: Maximum file size for the banner
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
logofile:
|
||||||
|
description: The file to upload.
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
encoding:
|
||||||
|
logofile:
|
||||||
|
contentType: image/png, image/jpeg
|
||||||
|
|
||||||
|
'/api/v1/config/instance-logo/:logoType':
|
||||||
|
delete:
|
||||||
|
summary: Delete instance logo
|
||||||
|
security:
|
||||||
|
- OAuth2:
|
||||||
|
- admin
|
||||||
|
tags:
|
||||||
|
- Config
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/logoTypeParam'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: successful operation
|
||||||
|
|
||||||
/api/v1/custom-pages/homepage/instance:
|
/api/v1/custom-pages/homepage/instance:
|
||||||
get:
|
get:
|
||||||
summary: Get instance custom homepage
|
summary: Get instance custom homepage
|
||||||
|
@ -8022,7 +8071,17 @@ components:
|
||||||
not valid anymore and you need to initialize a new upload.
|
not valid anymore and you need to initialize a new upload.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
logoTypeParam:
|
||||||
|
name: logoType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- 'favicon'
|
||||||
|
- 'header-wide'
|
||||||
|
- 'header-square'
|
||||||
|
- 'opengraph'
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
OAuth2:
|
OAuth2:
|
||||||
|
@ -9156,10 +9215,10 @@ components:
|
||||||
type: number
|
type: number
|
||||||
description: "**PeerTube >= 6.1** Frames per second of the video file"
|
description: "**PeerTube >= 6.1** Frames per second of the video file"
|
||||||
width:
|
width:
|
||||||
type: number
|
type: integer
|
||||||
description: "**PeerTube >= 6.1** Video stream width"
|
description: "**PeerTube >= 6.1** Video stream width"
|
||||||
height:
|
height:
|
||||||
type: number
|
type: integer
|
||||||
description: "**PeerTube >= 6.1** Video stream height"
|
description: "**PeerTube >= 6.1** Video stream height"
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
|
@ -9170,6 +9229,9 @@ components:
|
||||||
type: string
|
type: string
|
||||||
width:
|
width:
|
||||||
type: integer
|
type: integer
|
||||||
|
height:
|
||||||
|
type: integer
|
||||||
|
description: "**PeerTube >= 7.3** ImportVideosInChannelCreate:mage height"
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
|
# Check config/production.yaml.example in PeerTube repository for more details/available configuration
|
||||||
|
|
||||||
listen:
|
listen:
|
||||||
hostname: '0.0.0.0'
|
hostname: '0.0.0.0'
|
||||||
port: 9000
|
port: 9000
|
||||||
|
|
||||||
# Correspond to your reverse proxy server_name/listen configuration (i.e., your public PeerTube instance URL)
|
|
||||||
webserver:
|
webserver:
|
||||||
https: true
|
https: true
|
||||||
hostname: undefined
|
hostname: undefined
|
||||||
|
@ -10,23 +11,17 @@ webserver:
|
||||||
|
|
||||||
rates_limit:
|
rates_limit:
|
||||||
login:
|
login:
|
||||||
# 15 attempts in 5 min
|
|
||||||
window: 5 minutes
|
window: 5 minutes
|
||||||
max: 15
|
max: 15
|
||||||
ask_send_email:
|
ask_send_email:
|
||||||
# 3 attempts in 5 min
|
|
||||||
window: 5 minutes
|
window: 5 minutes
|
||||||
max: 3
|
max: 3
|
||||||
|
|
||||||
# Proxies to trust to get real client IP
|
|
||||||
# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
|
|
||||||
# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
|
|
||||||
trust_proxy:
|
trust_proxy:
|
||||||
- 'loopback'
|
- 'loopback'
|
||||||
- 'linklocal'
|
- 'linklocal'
|
||||||
- 'uniquelocal'
|
- 'uniquelocal'
|
||||||
|
|
||||||
# Your database name will be database.name OR 'peertube'+database.suffix
|
|
||||||
database:
|
database:
|
||||||
hostname: 'postgres'
|
hostname: 'postgres'
|
||||||
port: 5432
|
port: 5432
|
||||||
|
@ -35,7 +30,6 @@ database:
|
||||||
username: 'postgres'
|
username: 'postgres'
|
||||||
password: 'postgres'
|
password: 'postgres'
|
||||||
|
|
||||||
# Redis server for short time storage
|
|
||||||
redis:
|
redis:
|
||||||
hostname: 'redis'
|
hostname: 'redis'
|
||||||
port: 6379
|
port: 6379
|
||||||
|
@ -43,8 +37,8 @@ redis:
|
||||||
|
|
||||||
# From the project root directory
|
# From the project root directory
|
||||||
storage:
|
storage:
|
||||||
tmp: '../data/tmp/' # Use to download data (imports etc), store uploaded files before and during processing...
|
tmp: '../data/tmp/'
|
||||||
tmp_persistent: '../data/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts
|
tmp_persistent: '../data/tmp-persistent/'
|
||||||
bin: '../data/bin/'
|
bin: '../data/bin/'
|
||||||
avatars: '../data/avatars/'
|
avatars: '../data/avatars/'
|
||||||
web_videos: '../data/web-videos/'
|
web_videos: '../data/web-videos/'
|
||||||
|
@ -59,17 +53,8 @@ storage:
|
||||||
captions: '../data/captions/'
|
captions: '../data/captions/'
|
||||||
cache: '../data/cache/'
|
cache: '../data/cache/'
|
||||||
plugins: '../data/plugins/'
|
plugins: '../data/plugins/'
|
||||||
|
uploads: '../data/uploads/'
|
||||||
well_known: '../data/well-known/'
|
well_known: '../data/well-known/'
|
||||||
# Overridable client files in client/dist/assets/images:
|
|
||||||
# - logo.svg
|
|
||||||
# - favicon.png
|
|
||||||
# - default-playlist.jpg
|
|
||||||
# - default-avatar-account.png
|
|
||||||
# - default-avatar-video-channel.png
|
|
||||||
# - and icons/*.png (PWA)
|
|
||||||
# Could contain for example assets/images/favicon.png
|
|
||||||
# If the file exists, peertube will serve it
|
|
||||||
# If not, peertube will fallback to the default file
|
|
||||||
client_overrides: '../data/client-overrides/'
|
client_overrides: '../data/client-overrides/'
|
||||||
|
|
||||||
|
|
||||||
|
@ -79,7 +64,7 @@ object_storage:
|
||||||
private: null
|
private: null
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: 'info' # 'debug' | 'info' | 'warn' | 'error'
|
level: 'info'
|
||||||
|
|
||||||
tracker:
|
tracker:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
|
@ -190,7 +190,7 @@ server {
|
||||||
|
|
||||||
# Bypass PeerTube for performance reasons. Optional.
|
# Bypass PeerTube for performance reasons. Optional.
|
||||||
# Should be consistent with client-overrides assets list in client.ts server controller
|
# Should be consistent with client-overrides assets list in client.ts server controller
|
||||||
location ~ ^/client/(assets/images/(icons/icon-36x36\.png|icons/icon-48x48\.png|icons/icon-72x72\.png|icons/icon-96x96\.png|icons/icon-144x144\.png|icons/icon-192x192\.png|icons/icon-512x512\.png|logo\.svg|favicon\.png|default-playlist\.jpg|default-avatar-account\.png|default-avatar-account-48x48\.png|default-avatar-video-channel\.png|default-avatar-video-channel-48x48\.png))$ {
|
location ~ ^/client/(assets/images/(default-playlist\.jpg|default-avatar-account\.png|default-avatar-account-48x48\.png|default-avatar-video-channel\.png|default-avatar-video-channel-48x48\.png))$ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable"; # Cache 1 year
|
add_header Cache-Control "public, max-age=31536000, immutable"; # Cache 1 year
|
||||||
|
|
||||||
root /var/www/peertube;
|
root /var/www/peertube;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue