1
0
Fork 0
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:
Chocobozzz 2025-06-19 14:25:54 +02:00
parent f5fd593976
commit c0f4de6077
No known key found for this signature in database
GPG key ID: 583A612D890159BE
96 changed files with 1910 additions and 532 deletions

View file

@ -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"

View file

@ -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`,

View file

@ -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,

View file

@ -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>

View file

@ -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,

View file

@ -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>

View file

@ -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;
}

View file

@ -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)
}
}

View 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
}
}

View file

@ -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">

View file

@ -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;
} }

View file

@ -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">

View file

@ -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;
}
} }
} }

View file

@ -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>

View file

@ -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 {

View file

@ -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()

View file

@ -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>

View file

@ -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;
} }
} }

View file

@ -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
} }
} }
} }

View file

@ -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>

View file

@ -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()
} }
} }

View file

@ -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

View file

@ -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,

View file

@ -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()

View file

@ -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,

View file

@ -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="/">

View file

@ -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"
}
]
}

View file

@ -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"

View file

@ -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;

View file

@ -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">

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View 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
}

View file

@ -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'

View file

@ -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)

View file

@ -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

View file

@ -79,6 +79,10 @@ export interface CustomConfig {
} }
client: { client: {
header: {
hideInstanceName: boolean
}
videos: { videos: {
miniature: { miniature: {
preferAuthorDisplayName: boolean preferAuthorDisplayName: boolean

View file

@ -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'

View file

@ -0,0 +1 @@
export type LogoType = 'favicon' | 'header-wide' | 'header-square' | 'opengraph'

View file

@ -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: {

View 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]

View file

@ -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'

View file

@ -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: {

View file

@ -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 ])
}) })

View file

@ -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 () {

View file

@ -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` })
} }
{ {

View file

@ -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` })
} }
} }
}) })

View file

@ -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]))

View file

@ -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)

View file

@ -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) {

View file

@ -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
} }

View file

@ -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' ]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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,

View file

@ -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}`,

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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
}

View 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)
}

View file

@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -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',

View file

@ -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')

View file

@ -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()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -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.`

View 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
}

View file

@ -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
}) { }) {

View file

@ -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)
}
} }

View file

@ -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)
} }

View file

@ -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())
} }

View 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
}
}

View file

@ -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')

View file

@ -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

View 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()
}
]

View file

@ -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'

View file

@ -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)]
}
} }

View file

@ -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

View file

@ -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

View 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
}
}

View file

@ -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)

View file

@ -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'>

View file

@ -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

View file

@ -1 +1,2 @@
export * from './application.js' export * from './application.js'
export * from './upload-image.js'

View file

@ -0,0 +1,3 @@
import { UploadImageModel } from '@server/models/application/upload-image.js'
export type MUploadImage = UploadImageModel

View file

@ -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

View file

@ -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

View file

@ -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;