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