Add ability to set avatar to instance

pull/6266/head
Chocobozzz 2024-02-23 14:27:11 +01:00
parent db06d13c67
commit bb7cb0d2fd
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
29 changed files with 693 additions and 348 deletions

View File

@ -8,11 +8,25 @@
</div>
<div class="col-12 col-lg-8 col-xl-9">
<div class="form-group">
<label i18n for="avatarfile">Square icon</label>
<div class="label-small-info">
<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="label-small-info">
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages.</p>
<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>

View File

@ -2,10 +2,11 @@ import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnInit } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { CustomMarkupService } from '@app/shared/shared-custom-markup'
import { Notifier } from '@app/core'
import { Notifier, ServerService } from '@app/core'
import { HttpErrorResponse } from '@angular/common/http'
import { genericUploadErrorHandler } from '@app/helpers'
import { InstanceService } from '@app/shared/shared-instance'
import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models'
@Component({
selector: 'my-edit-instance-information',
@ -20,17 +21,27 @@ export class EditInstanceInformationComponent implements OnInit {
@Input() categoryItems: SelectOptionsItem[] = []
instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
private serverConfig: HTMLServerConfig
constructor (
private customMarkup: CustomMarkupService,
private notifier: Notifier,
private instanceService: InstanceService
private instanceService: InstanceService,
private server: ServerService
) {
}
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () {
this.resetBannerUrl()
this.serverConfig = this.server.getHTMLConfig()
this.updateActorImages()
}
getCustomMarkdownRenderer () {
@ -39,15 +50,15 @@ export class EditInstanceInformationComponent implements OnInit {
onBannerChange (formData: FormData) {
this.instanceService.updateInstanceBanner(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Banner changed.`)
.subscribe({
next: () => {
this.notifier.success($localize`Banner changed.`)
this.resetBannerUrl()
},
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
})
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
})
}
onBannerDelete () {
@ -56,17 +67,51 @@ export class EditInstanceInformationComponent implements OnInit {
next: () => {
this.notifier.success($localize`Banner deleted.`)
this.resetBannerUrl()
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
private resetBannerUrl () {
this.instanceService.getInstanceBannerUrl()
.subscribe(instanceBannerUrl => {
this.instanceBannerUrl = instanceBannerUrl
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 = this.serverConfig.instance.banners?.[0]?.path
this.instanceAvatars = this.serverConfig.instance.avatars
}
private resetActorImages () {
this.server.resetConfig()
.subscribe(config => {
this.serverConfig = config
this.updateActorImages()
})
}
}

View File

@ -70,10 +70,18 @@
<div class="row mt-4"> <!-- user grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="user"></div> <!-- user anchor -->
<div *ngIf="isCreation()" class="section-left-column-title" i18n>NEW USER</div>
<div *ngIf="!isCreation() && user" class="section-left-column-title">
<my-actor-avatar-edit [actor]="user.account" [editable]="false" [displaySubscribers]="false" [displayUsername]="false"></my-actor-avatar-edit>
</div>
@if (isCreation()) {
<div class="section-left-column-title" i18n>NEW USER</div>
} @else if (user) {
<div class="section-left-column-title">
<my-actor-avatar-edit
actorType="account" [displayName]="user.account.displayName" [avatars]="user.account.avatars"
editable="false" [username]="user.username" displayUsername="false"
></my-actor-avatar-edit>
</div>
}
</div>
<div class="col-12 col-lg-8 col-xl-9">

View File

@ -16,9 +16,10 @@
></my-actor-banner-edit>
<my-actor-avatar-edit
*ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4"
[actor]="videoChannel" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
[displayUsername]="!isCreation()" [displaySubscribers]="!isCreation()"
*ngIf="videoChannel" class="d-block mb-4" actorType="channel"
[displayName]="videoChannel.displayName" [previewImage]="isCreation()" [avatars]="videoChannel.avatars"
[username]="!isCreation() && videoChannel.displayName" [subscribers]="!isCreation() && videoChannel.followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
<div class="form-group" *ngIf="isCreation()">

View File

@ -4,7 +4,11 @@
<div class="col-12 col-lg-4 col-xl-3"></div>
<div class="col-12 col-lg-8 col-xl-9">
<my-actor-avatar-edit [actor]="user.account" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"></my-actor-avatar-edit>
<my-actor-avatar-edit
actorType="account" [avatars]="user.account.avatars"
[displayName]="user.account.displayName" [username]="user.username" [subscribers]="user.account.followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
</div>
</div>

View File

@ -1,6 +1,6 @@
<div class="actor" *ngIf="actor">
<div class="position-relative me-3">
<my-actor-avatar [actor]="actor" [actorType]="getActorType()" [previewImage]="preview" size="100"></my-actor-avatar>
<my-actor-avatar [actor]="actor" [actorType]="actorType" [previewImage]="preview" size="100"></my-actor-avatar>
<div *ngIf="editable && !hasAvatar()" class="actor-img-edit-button button-focus-within" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
@ -31,8 +31,8 @@
</div>
<div class="actor-info">
<div class="actor-info-display-name">{{ actor.displayName }}</div>
<div *ngIf="displayUsername" class="actor-info-username">{{ actor.name }}</div>
<div *ngIf="displaySubscribers" i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
<div *ngIf="displayName" class="actor-info-display-name">{{ displayName }}</div>
<div *ngIf="displayUsername && username" class="actor-info-username">{{ username }}</div>
<div *ngIf="subscribers" i18n class="actor-info-followers">{{ subscribers }} subscribers</div>
</div>
</div>

View File

@ -1,8 +1,9 @@
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild, booleanAttribute } from '@angular/core'
import { Notifier, ServerService } from '@app/core'
import { Account, VideoChannel } from '@app/shared/shared-main'
import { getBytes } from '@root-helpers/bytes'
import { imageToDataURL } from '@root-helpers/images'
import { ActorAvatarInput } from '../shared-actor-image/actor-avatar.component'
import { ActorImage } from '@peertube/peertube-models'
@Component({
selector: 'my-actor-avatar-edit',
@ -12,14 +13,19 @@ import { imageToDataURL } from '@root-helpers/images'
'./actor-avatar-edit.component.scss'
]
})
export class ActorAvatarEditComponent implements OnInit {
export class ActorAvatarEditComponent implements OnInit, OnChanges {
@ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
@Input() actor: VideoChannel | Account
@Input() editable = true
@Input() displaySubscribers = true
@Input() displayUsername = true
@Input() previewImage = false
@Input({ required: true }) actorType: 'channel' | 'account'
@Input({ required: true }) avatars: ActorImage[]
@Input({ required: true }) username: string
@Input() displayName: string
@Input() subscribers: number
@Input({ transform: booleanAttribute }) displayUsername = true
@Input({ transform: booleanAttribute }) editable = true
@Input({ transform: booleanAttribute }) previewImage = false
@Output() avatarChange = new EventEmitter<FormData>()
@Output() avatarDelete = new EventEmitter<void>()
@ -30,6 +36,8 @@ export class ActorAvatarEditComponent implements OnInit {
preview: string
actor: ActorAvatarInput
constructor (
private serverService: ServerService,
private notifier: Notifier
@ -41,8 +49,14 @@ export class ActorAvatarEditComponent implements OnInit {
this.maxAvatarSize = config.avatar.file.size.max
this.avatarExtensions = config.avatar.file.extensions.join(', ')
this.avatarFormat = `${$localize`max size`}: 192*192px, ` +
`${getBytes(this.maxAvatarSize)} ${$localize`extensions`}: ${this.avatarExtensions}`
this.avatarFormat = $localize`max size: 192*192px, ${getBytes(this.maxAvatarSize)} extensions: ${this.avatarExtensions}`
}
ngOnChanges () {
this.actor = {
avatars: this.avatars,
name: this.username
}
}
onAvatarChange (input: HTMLInputElement) {
@ -69,12 +83,6 @@ export class ActorAvatarEditComponent implements OnInit {
}
hasAvatar () {
return !!this.preview || this.actor.avatars.length !== 0
}
getActorType () {
if ((this.actor as VideoChannel).ownerAccount) return 'channel'
return 'account'
return !!this.preview || this.avatars.length !== 0
}
}

View File

@ -1,11 +1,11 @@
<ng-template #img>
<img *ngIf="displayImage()" [class]="classes" [src]="previewImage || avatarUrl || defaultAvatarUrl" alt="" />
<img #avatarEl *ngIf="displayImage()" [ngClass]="classes" [src]="previewImage || avatarUrl || defaultAvatarUrl" alt="" />
<div *ngIf="displayActorInitial()" [ngClass]="classes">
<div #avatarEl *ngIf="displayActorInitial()" [ngClass]="classes">
<span>{{ getActorInitial() }}</span>
</div>
<div *ngIf="displayPlaceholder()" [ngClass]="classes"></div>
<div #avatarEl *ngIf="displayPlaceholder()" [ngClass]="classes"></div>
</ng-template>
<a *ngIf="actor && href" [href]="href" target="_blank" rel="noopener noreferrer" [title]="title">

View File

@ -2,9 +2,7 @@
@use '_mixins' as *;
.avatar {
--avatarSize: 100%;
--initialFontSize: 22px;
// Defined in component
width: var(--avatarSize);
height: var(--avatarSize);
min-width: var(--avatarSize);
@ -20,26 +18,6 @@
}
}
$sizes: '18', '25', '28', '32', '34', '35', '36', '40', '48', '75', '80', '100', '120';
@each $size in $sizes {
.avatar-#{$size} {
--avatarSize: #{$size}px;
}
}
.avatar-18 {
--initialFontSize: 13px;
}
.avatar-100 {
--initialFontSize: 40px;
}
.avatar-120 {
--initialFontSize: 46px;
}
a:hover {
text-decoration: none;
}

View File

@ -1,36 +1,35 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core'
import { Component, ElementRef, Input, OnChanges, OnInit, ViewChild, numberAttribute } from '@angular/core'
import { VideoChannel } from '../shared-main'
import { Account } from '../shared-main/account/account.model'
import { objectKeysTyped } from '@peertube/peertube-core-utils'
type ActorInput = {
export type ActorAvatarInput = {
name: string
avatars: { width: number, url?: string, path: string }[]
url: string
}
export type ActorAvatarSize = '18' | '25' | '28' | '32' | '34' | '35' | '36' | '40' | '48' | '75' | '80' | '100' | '120'
@Component({
selector: 'my-actor-avatar',
styleUrls: [ './actor-avatar.component.scss' ],
templateUrl: './actor-avatar.component.html'
})
export class ActorAvatarComponent implements OnInit, OnChanges {
private _title: string
@ViewChild('avatarEl') avatarEl: ElementRef
@Input() actor: ActorInput
@Input() actor: ActorAvatarInput
@Input() actorType: 'channel' | 'account' | 'unlogged'
@Input() previewImage: string
@Input() size: ActorAvatarSize
@Input({ transform: numberAttribute }) size: number
// Use an external link
@Input() href: string
// Use routerLink
@Input() internalHref: string | any[]
private _title: string
@Input() set title (value) {
this._title = value
}
@ -47,6 +46,10 @@ export class ActorAvatarComponent implements OnInit, OnChanges {
defaultAvatarUrl: string
avatarUrl: string
constructor (private el: ElementRef) {
}
ngOnInit () {
this.buildDefaultAvatarUrl()
@ -60,10 +63,21 @@ export class ActorAvatarComponent implements OnInit, OnChanges {
}
private buildClasses () {
let avatarSize = '100%'
let initialFontSize = '22px'
this.classes = [ 'avatar' ]
if (this.size) {
this.classes.push(`avatar-${this.size}`)
avatarSize = `${this.size}px`
if (this.size <= 18) {
initialFontSize = '13px'
} else if (this.size >= 100) {
initialFontSize = '40px'
} else if (this.size >= 120) {
initialFontSize = '46px'
}
}
if (this.isChannel()) {
@ -77,6 +91,10 @@ export class ActorAvatarComponent implements OnInit, OnChanges {
this.classes.push('initial')
this.classes.push(this.getColorTheme())
}
const elStyle = (this.el.nativeElement as HTMLElement).style
elStyle.setProperty('--avatarSize', avatarSize)
elStyle.setProperty('--initialFontSize', initialFontSize)
}
private buildDefaultAvatarUrl () {

View File

@ -6,6 +6,7 @@ import {
ChannelMiniatureMarkupData,
ContainerMarkupData,
EmbedMarkupData,
InstanceAvatarMarkupData,
InstanceBannerMarkupData,
PlaylistMiniatureMarkupData,
VideoMiniatureMarkupData,
@ -16,6 +17,7 @@ import {
ButtonMarkupComponent,
ChannelMiniatureMarkupComponent,
EmbedMarkupComponent,
InstanceAvatarMarkupComponent,
InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent,
@ -30,6 +32,7 @@ type HTMLBuilderFunction = (el: HTMLElement) => HTMLElement
export class CustomMarkupService {
private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = {
'peertube-instance-banner': el => this.instanceBannerBuilder(el),
'peertube-instance-avatar': el => this.instanceAvatarBuilder(el),
'peertube-button': el => this.buttonBuilder(el),
'peertube-video-embed': el => this.embedBuilder(el, 'video'),
'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'),
@ -171,6 +174,19 @@ export class CustomMarkupService {
return { component, loadedPromise }
}
private instanceAvatarBuilder (el: HTMLElement) {
const data = el.dataset as InstanceAvatarMarkupData
const { component, loadedPromise } = this.dynamicElementService.createElement(InstanceAvatarMarkupComponent)
const model = {
size: this.buildNumber(data.size)
}
this.dynamicElementService.setModel(component, model)
return { component, loadedPromise }
}
private videoMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as VideoMiniatureMarkupData
const { component, loadedPromise } = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent)

View File

@ -1,6 +1,7 @@
export * from './button-markup.component'
export * from './channel-miniature-markup.component'
export * from './embed-markup.component'
export * from './instance-avatar-markup.component'
export * from './instance-banner-markup.component'
export * from './playlist-miniature-markup.component'
export * from './video-miniature-markup.component'

View File

@ -0,0 +1 @@
<my-actor-avatar *ngIf="actor" [actor]="actor" actorType="account" [size]="size"></my-actor-avatar>

View File

@ -0,0 +1,36 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'
import { CustomMarkupComponent } from './shared'
import { ActorAvatarInput } from '@app/shared/shared-actor-image/actor-avatar.component'
import { ServerService } from '@app/core'
/*
* Markup component that creates the img HTML element containing the instance avatar
*/
@Component({
selector: 'my-instance-avatar-markup',
templateUrl: 'instance-avatar-markup.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InstanceAvatarMarkupComponent implements OnInit, CustomMarkupComponent {
@Input() size: number
actor: ActorAvatarInput
loaded: undefined
constructor (
private cd: ChangeDetectorRef,
private server: ServerService
) {}
ngOnInit () {
const { instance } = this.server.getHTMLConfig()
this.actor = {
avatars: instance.avatars,
name: this.server.getHTMLConfig().instance.name
}
this.cd.markForCheck()
}
}

View File

@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'
import { CustomMarkupComponent } from './shared'
import { InstanceService } from '@app/shared/shared-instance'
import { finalize } from 'rxjs'
import { ServerService } from '@app/core'
/*
* Markup component that creates the img HTML element containing the instance banner
@ -15,21 +14,18 @@ import { finalize } from 'rxjs'
export class InstanceBannerMarkupComponent implements OnInit, CustomMarkupComponent {
@Input() revertHomePaddingTop: boolean
@Output() loaded = new EventEmitter<boolean>()
instanceBannerUrl: string
loaded: undefined
constructor (
private cd: ChangeDetectorRef,
private instance: InstanceService
private server: ServerService
) {}
ngOnInit () {
this.instance.getInstanceBannerUrl()
.pipe(finalize(() => this.loaded.emit(true)))
.subscribe(instanceBannerUrl => {
this.instanceBannerUrl = instanceBannerUrl
this.cd.markForCheck()
})
const { instance } = this.server.getHTMLConfig()
this.instanceBannerUrl = instance.banners?.[0]?.path
this.cd.markForCheck()
}
}

View File

@ -14,6 +14,7 @@ import {
ButtonMarkupComponent,
ChannelMiniatureMarkupComponent,
EmbedMarkupComponent,
InstanceAvatarMarkupComponent,
InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent,
@ -41,7 +42,8 @@ import {
ButtonMarkupComponent,
CustomMarkupHelpComponent,
CustomMarkupContainerComponent,
InstanceBannerMarkupComponent
InstanceBannerMarkupComponent,
InstanceAvatarMarkupComponent
],
exports: [
@ -53,7 +55,8 @@ import {
ButtonMarkupComponent,
CustomMarkupHelpComponent,
CustomMarkupContainerComponent,
InstanceBannerMarkupComponent
InstanceBannerMarkupComponent,
InstanceAvatarMarkupComponent
],
providers: [

View File

@ -1,5 +1,5 @@
import { Component, Input, OnInit, booleanAttribute } from '@angular/core'
import { InstanceService } from './instance.service'
import { ServerService } from '@app/core'
@Component({
selector: 'my-instance-banner',
@ -10,12 +10,13 @@ export class InstanceBannerComponent implements OnInit {
instanceBannerUrl: string
constructor (private instanceService: InstanceService) {
constructor (private server: ServerService) {
}
ngOnInit () {
this.instanceService.getInstanceBannerUrl()
.subscribe(instanceBannerUrl => this.instanceBannerUrl = instanceBannerUrl)
const { instance } = this.server.getHTMLConfig()
this.instanceBannerUrl = instance.banners?.[0]?.path
}
}

View File

@ -1,5 +1,5 @@
import { forkJoin, of } from 'rxjs'
import { catchError, map, tap } from 'rxjs/operators'
import { forkJoin } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { MarkdownService, RestExtractor, ServerService } from '@app/core'
@ -18,8 +18,6 @@ export class InstanceService {
private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
private instanceBannerUrl: string
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor,
@ -30,29 +28,12 @@ export class InstanceService {
getAbout () {
return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
.pipe(
tap(about => {
const banners = about.instance.banners
if (banners.length !== 0) this.instanceBannerUrl = banners[0].path
}),
catchError(res => this.restExtractor.handleError(res))
)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
// ---------------------------------------------------------------------------
getInstanceBannerUrl () {
if (this.instanceBannerUrl || this.instanceBannerUrl === null) {
return of(this.instanceBannerUrl)
}
return this.getAbout()
.pipe(map(() => this.instanceBannerUrl))
}
updateInstanceBanner (formData: FormData) {
this.instanceBannerUrl = undefined
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner/pick'
return this.authHttp.post(url, formData)
@ -60,8 +41,6 @@ export class InstanceService {
}
deleteInstanceBanner () {
this.instanceBannerUrl = null
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner'
return this.authHttp.delete(url)
@ -70,6 +49,22 @@ export class InstanceService {
// ---------------------------------------------------------------------------
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,

View File

@ -8,12 +8,12 @@ import {
Input,
LOCALE_ID,
OnInit,
Output
Output,
numberAttribute
} from '@angular/core'
import { AuthService, ScreenService, ServerService, User } from '@app/core'
import { HTMLServerConfig, VideoExistInPlaylist, VideoPlaylistType, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { LinkType } from '../../../types/link.type'
import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
import { Video, VideoService } from '../shared-main'
import { VideoPlaylistService } from '../shared-video-playlist'
import { VideoActionsDisplayType } from './video-actions-dropdown.component'
@ -68,7 +68,7 @@ export class VideoMiniatureComponent implements OnInit {
stats: false
}
@Input() actorImageSize: ActorAvatarSize = '40'
@Input({ transform: numberAttribute }) actorImageSize = 40
@Input() displayAsRow = false

View File

@ -62,3 +62,7 @@ export type ContainerMarkupData = {
export type InstanceBannerMarkupData = {
revertHomePaddingTop?: StringBoolean // default to 'true'
}
export type InstanceAvatarMarkupData = {
size: string // size in pixels
}

View File

@ -20,5 +20,6 @@ export interface About {
categories: number[]
banners: ActorImage[]
avatars: ActorImage[]
}
}

View File

@ -1,3 +1,4 @@
import { ActorImage } 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'
@ -90,6 +91,9 @@ export interface ServerConfig {
javascript: string
css: string
}
avatars: ActorImage[]
banners: ActorImage[]
}
search: {

View File

@ -1,5 +1,5 @@
import merge from 'lodash-es/merge.js'
import { About, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models'
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models'
import { DeepPartial } from '@peertube/peertube-typescript-utils'
import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js'
@ -365,27 +365,38 @@ export class ConfigCommand extends AbstractCommand {
// ---------------------------------------------------------------------------
updateInstanceBanner (options: OverrideCommandOptions & {
updateInstanceImage (options: OverrideCommandOptions & {
fixture: string
type: ActorImageType_Type
}) {
const { fixture } = options
const { fixture, type } = options
const path = `/api/v1/config/instance-banner/pick`
const path = type === ActorImageType.BANNER
? `/api/v1/config/instance-banner/pick`
: `/api/v1/config/instance-avatar/pick`
return this.updateImageRequest({
...options,
path,
fixture,
fieldname: 'bannerfile',
fieldname: type === ActorImageType.BANNER
? 'bannerfile'
: 'avatarfile',
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
deleteInstanceBanner (options: OverrideCommandOptions = {}) {
const path = `/api/v1/config/instance-banner`
deleteInstanceImage (options: OverrideCommandOptions & {
type: ActorImageType_Type
}) {
const suffix = options.type === ActorImageType.BANNER
? 'instance-banner'
: 'instance-avatar'
const path = `/api/v1/config/${suffix}`
return this.deleteRequest({
...options,

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import merge from 'lodash-es/merge.js'
import { omit } from '@peertube/peertube-core-utils'
import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import {
cleanupTests,
createSingleServer,
@ -421,6 +421,7 @@ describe('Test config API validators', function () {
})
describe('When deleting the configuration', function () {
it('Should fail without token', async function () {
await makeDeleteRequest({
url: server.url,
@ -439,79 +440,99 @@ describe('Test config API validators', function () {
})
})
describe('Updating instance banner', function () {
const path = '/api/v1/config/instance-banner/pick'
describe('Updating instance image', function () {
const toTest = [
{ path: '/api/v1/config/instance-banner/pick', attachName: 'bannerfile' },
{ path: '/api/v1/config/instance-avatar/pick', attachName: 'avatarfile' }
]
it('Should fail with an incorrect input file', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('video_short.mp4') }
for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('video_short.mp4') }
await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields: {}, attaches })
await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields: {}, attaches })
}
})
it('Should fail with a big file', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar-big.png') }
for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar-big.png') }
await makeUploadRequest({
url: server.url,
path,
token: server.accessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
await makeUploadRequest({
url: server.url,
path,
token: server.accessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
}
})
it('Should fail without token', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') }
for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({
url: server.url,
path,
fields: {},
attaches,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
await makeUploadRequest({
url: server.url,
path,
fields: {},
attaches,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
}
})
it('Should fail without the appropriate rights', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') }
for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({
url: server.url,
path,
token: userAccessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
await makeUploadRequest({
url: server.url,
path,
token: userAccessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
}
})
it('Should succeed with the correct params', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') }
for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({
url: server.url,
path,
token: server.accessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.NO_CONTENT_204
})
await makeUploadRequest({
url: server.url,
path,
token: server.accessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
})
})
describe('Deleting instance banner', function () {
describe('Deleting instance image', function () {
const types = [ ActorImageType.BANNER, ActorImageType.AVATAR ]
it('Should fail without token', async function () {
await server.config.deleteInstanceBanner({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
for (const type of types) {
await server.config.deleteInstanceImage({ type, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
}
})
it('Should fail without the appropriate rights', async function () {
await server.config.deleteInstanceBanner({ token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
for (const type of types) {
await server.config.deleteInstanceImage({ type, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
}
})
it('Should succeed with the correct params', async function () {
await server.config.deleteInstanceBanner()
for (const type of types) {
await server.config.deleteInstanceImage({ type })
}
})
})

View File

@ -2,7 +2,7 @@
import { expect } from 'chai'
import { parallelTests } from '@peertube/peertube-node-utils'
import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import {
cleanupTests,
createSingleServer,
@ -11,7 +11,7 @@ import {
PeerTubeServer,
setAccessTokensToServers
} from '@peertube/peertube-server-commands'
import { testFileExistsOrNot, testImage } from '@tests/shared/checks.js'
import { testFileExistsOrNot, testImage, testImageSize } from '@tests/shared/checks.js'
import { basename } from 'path'
function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
@ -521,7 +521,6 @@ describe('Test static config', function () {
describe('Test config', function () {
let server: PeerTubeServer
let bannerPath: string
before(async function () {
this.timeout(30000)
@ -530,184 +529,237 @@ describe('Test config', function () {
await setAccessTokensToServers([ server ])
})
it('Should have the correct default config', async function () {
const data = await server.config.getConfig()
describe('Config keys', function () {
expect(data.openTelemetry.metrics.enabled).to.be.false
expect(data.openTelemetry.metrics.playbackStatsInterval).to.equal(15000)
it('Should have the correct default config', async function () {
const data = await server.config.getConfig()
expect(data.views.videos.watchingInterval.anonymous).to.equal(5000)
expect(data.views.videos.watchingInterval.users).to.equal(5000)
})
expect(data.openTelemetry.metrics.enabled).to.be.false
expect(data.openTelemetry.metrics.playbackStatsInterval).to.equal(15000)
it('Should have a correct config on a server with registration enabled', async function () {
const data = await server.config.getConfig()
expect(data.views.videos.watchingInterval.anonymous).to.equal(5000)
expect(data.views.videos.watchingInterval.users).to.equal(5000)
})
expect(data.signup.allowed).to.be.true
})
it('Should have a correct config on a server with registration enabled', async function () {
const data = await server.config.getConfig()
it('Should have a correct config on a server with registration enabled and a users limit', async function () {
this.timeout(5000)
expect(data.signup.allowed).to.be.true
})
await Promise.all([
server.registrations.register({ username: 'user1' }),
server.registrations.register({ username: 'user2' }),
server.registrations.register({ username: 'user3' })
])
it('Should have a correct config on a server with registration enabled and a users limit', async function () {
this.timeout(5000)
const data = await server.config.getConfig()
await Promise.all([
server.registrations.register({ username: 'user1' }),
server.registrations.register({ username: 'user2' }),
server.registrations.register({ username: 'user3' })
])
expect(data.signup.allowed).to.be.false
})
const data = await server.config.getConfig()
it('Should have the correct video allowed extensions', async function () {
const data = await server.config.getConfig()
expect(data.signup.allowed).to.be.false
})
expect(data.video.file.extensions).to.have.lengthOf(3)
expect(data.video.file.extensions).to.contain('.mp4')
expect(data.video.file.extensions).to.contain('.webm')
expect(data.video.file.extensions).to.contain('.ogv')
it('Should have the correct video allowed extensions', async function () {
const data = await server.config.getConfig()
await server.videos.upload({ attributes: { fixture: 'video_short.mkv' }, expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 })
await server.videos.upload({ attributes: { fixture: 'sample.ogg' }, expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 })
expect(data.video.file.extensions).to.have.lengthOf(3)
expect(data.video.file.extensions).to.contain('.mp4')
expect(data.video.file.extensions).to.contain('.webm')
expect(data.video.file.extensions).to.contain('.ogv')
expect(data.contactForm.enabled).to.be.true
})
await server.videos.upload({ attributes: { fixture: 'video_short.mkv' }, expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 })
await server.videos.upload({ attributes: { fixture: 'sample.ogg' }, expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 })
it('Should get the customized configuration', async function () {
const data = await server.config.getCustomConfig()
expect(data.contactForm.enabled).to.be.true
})
checkInitialConfig(server, data)
})
it('Should get the customized configuration', async function () {
const data = await server.config.getCustomConfig()
it('Should update the customized configuration', async function () {
await server.config.updateCustomConfig({ newCustomConfig })
checkInitialConfig(server, data)
})
const data = await server.config.getCustomConfig()
checkUpdatedConfig(data)
})
it('Should update the customized configuration', async function () {
await server.config.updateCustomConfig({ newCustomConfig })
it('Should have the correct updated video allowed extensions', async function () {
this.timeout(30000)
const data = await server.config.getCustomConfig()
checkUpdatedConfig(data)
})
const data = await server.config.getConfig()
it('Should have the correct updated video allowed extensions', async function () {
this.timeout(30000)
expect(data.video.file.extensions).to.have.length.above(4)
expect(data.video.file.extensions).to.contain('.mp4')
expect(data.video.file.extensions).to.contain('.webm')
expect(data.video.file.extensions).to.contain('.ogv')
expect(data.video.file.extensions).to.contain('.flv')
expect(data.video.file.extensions).to.contain('.wmv')
expect(data.video.file.extensions).to.contain('.mkv')
expect(data.video.file.extensions).to.contain('.mp3')
expect(data.video.file.extensions).to.contain('.ogg')
expect(data.video.file.extensions).to.contain('.flac')
const data = await server.config.getConfig()
await server.videos.upload({ attributes: { fixture: 'video_short.mkv' }, expectedStatus: HttpStatusCode.OK_200 })
await server.videos.upload({ attributes: { fixture: 'sample.ogg' }, expectedStatus: HttpStatusCode.OK_200 })
})
expect(data.video.file.extensions).to.have.length.above(4)
expect(data.video.file.extensions).to.contain('.mp4')
expect(data.video.file.extensions).to.contain('.webm')
expect(data.video.file.extensions).to.contain('.ogv')
expect(data.video.file.extensions).to.contain('.flv')
expect(data.video.file.extensions).to.contain('.wmv')
expect(data.video.file.extensions).to.contain('.mkv')
expect(data.video.file.extensions).to.contain('.mp3')
expect(data.video.file.extensions).to.contain('.ogg')
expect(data.video.file.extensions).to.contain('.flac')
it('Should have the configuration updated after a restart', async function () {
this.timeout(30000)
await server.videos.upload({ attributes: { fixture: 'video_short.mkv' }, expectedStatus: HttpStatusCode.OK_200 })
await server.videos.upload({ attributes: { fixture: 'sample.ogg' }, expectedStatus: HttpStatusCode.OK_200 })
})
await killallServers([ server ])
it('Should have the configuration updated after a restart', async function () {
this.timeout(30000)
await server.run()
await killallServers([ server ])
const data = await server.config.getCustomConfig()
await server.run()
checkUpdatedConfig(data)
})
const data = await server.config.getCustomConfig()
it('Should fetch the about information', async function () {
const { instance } = await server.config.getAbout()
checkUpdatedConfig(data)
})
expect(instance.name).to.equal('PeerTube updated')
expect(instance.shortDescription).to.equal('my short description')
expect(instance.description).to.equal('my super description')
expect(instance.terms).to.equal('my super terms')
expect(instance.codeOfConduct).to.equal('my super coc')
it('Should fetch the about information', async function () {
const { instance } = await server.config.getAbout()
expect(instance.creationReason).to.equal('my super creation reason')
expect(instance.moderationInformation).to.equal('my super moderation information')
expect(instance.administrator).to.equal('Kuja')
expect(instance.maintenanceLifetime).to.equal('forever')
expect(instance.businessModel).to.equal('my super business model')
expect(instance.hardwareInformation).to.equal('2vCore 3GB RAM')
expect(instance.name).to.equal('PeerTube updated')
expect(instance.shortDescription).to.equal('my short description')
expect(instance.description).to.equal('my super description')
expect(instance.terms).to.equal('my super terms')
expect(instance.codeOfConduct).to.equal('my super coc')
expect(instance.languages).to.deep.equal([ 'en', 'es' ])
expect(instance.categories).to.deep.equal([ 1, 2 ])
expect(instance.creationReason).to.equal('my super creation reason')
expect(instance.moderationInformation).to.equal('my super moderation information')
expect(instance.administrator).to.equal('Kuja')
expect(instance.maintenanceLifetime).to.equal('forever')
expect(instance.businessModel).to.equal('my super business model')
expect(instance.hardwareInformation).to.equal('2vCore 3GB RAM')
expect(instance.banners).to.have.lengthOf(0)
})
expect(instance.languages).to.deep.equal([ 'en', 'es' ])
expect(instance.categories).to.deep.equal([ 1, 2 ])
it('Should update instance banner', async function () {
await server.config.updateInstanceBanner({ fixture: 'banner.jpg' })
expect(instance.banners).to.have.lengthOf(0)
})
const { instance } = await server.config.getAbout()
it('Should remove the custom configuration', async function () {
await server.config.deleteCustomConfig()
expect(instance.banners).to.have.lengthOf(1)
const data = await server.config.getCustomConfig()
checkInitialConfig(server, data)
})
bannerPath = instance.banners[0].path
await testImage(server.url, 'banner-resized', bannerPath)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), true)
})
it('Should enable/disable security headers', async function () {
this.timeout(25000)
it('Should re-update an existing instance banner', async function () {
await server.config.updateInstanceBanner({ fixture: 'banner.jpg' })
})
{
const res = await makeGetRequest({
url: server.url,
path: '/api/v1/config',
expectedStatus: 200
})
it('Should remove instance banner', async function () {
await server.config.deleteInstanceBanner()
const { instance } = await server.config.getAbout()
expect(instance.banners).to.have.lengthOf(0)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), false)
})
it('Should remove the custom configuration', async function () {
await server.config.deleteCustomConfig()
const data = await server.config.getCustomConfig()
checkInitialConfig(server, data)
})
it('Should enable/disable security headers', async function () {
this.timeout(25000)
{
const res = await makeGetRequest({
url: server.url,
path: '/api/v1/config',
expectedStatus: 200
})
expect(res.headers['x-frame-options']).to.exist
expect(res.headers['x-powered-by']).to.equal('PeerTube')
}
await killallServers([ server ])
const config = {
security: {
frameguard: { enabled: false },
powered_by_header: { enabled: false }
expect(res.headers['x-frame-options']).to.exist
expect(res.headers['x-powered-by']).to.equal('PeerTube')
}
}
await server.run(config)
{
const res = await makeGetRequest({
url: server.url,
path: '/api/v1/config',
expectedStatus: 200
await killallServers([ server ])
const config = {
security: {
frameguard: { enabled: false },
powered_by_header: { enabled: false }
}
}
await server.run(config)
{
const res = await makeGetRequest({
url: server.url,
path: '/api/v1/config',
expectedStatus: 200
})
expect(res.headers['x-frame-options']).to.not.exist
expect(res.headers['x-powered-by']).to.not.exist
}
})
})
describe('Image files', function () {
async function checkAndGetServerImages () {
const { instance } = await server.config.getAbout()
const htmlConfig = await server.config.getIndexHTMLConfig()
const serverConfig = await server.config.getIndexHTMLConfig()
expect(instance.avatars).to.deep.equal(htmlConfig.instance.avatars)
expect(serverConfig.instance.avatars).to.deep.equal(htmlConfig.instance.avatars)
expect(instance.banners).to.deep.equal(htmlConfig.instance.banners)
expect(serverConfig.instance.banners).to.deep.equal(htmlConfig.instance.banners)
return htmlConfig.instance
}
describe('Banner', function () {
let bannerPath: string
it('Should update instance banner', async function () {
await server.config.updateInstanceImage({ type: ActorImageType.BANNER, fixture: 'banner.jpg' })
const { banners } = await checkAndGetServerImages()
expect(banners).to.have.lengthOf(1)
bannerPath = banners[0].path
await testImage(server.url, 'banner-resized', bannerPath)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), true)
})
expect(res.headers['x-frame-options']).to.not.exist
expect(res.headers['x-powered-by']).to.not.exist
}
it('Should re-update an existing instance banner', async function () {
await server.config.updateInstanceImage({ type: ActorImageType.BANNER, fixture: 'banner.jpg' })
})
it('Should remove instance banner', async function () {
await server.config.deleteInstanceImage({ type: ActorImageType.BANNER })
const { banners } = await checkAndGetServerImages()
expect(banners).to.have.lengthOf(0)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), false)
})
})
describe('Avatar', function () {
let avatarPath: string
it('Should update instance avatar', async function () {
for (const extension of [ '.png', '.gif' ]) {
const fixture = 'avatar' + extension
await server.config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture })
const { avatars } = await checkAndGetServerImages()
for (const avatar of avatars) {
await testImageSize(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, extension)
}
avatarPath = avatars[0].path
await testFileExistsOrNot(server, 'avatars', basename(avatarPath), true)
}
})
it('Should remove instance banner', async function () {
await server.config.deleteInstanceImage({ type: ActorImageType.AVATAR })
const { avatars } = await checkAndGetServerImages()
expect(avatars).to.have.lengthOf(0)
await testFileExistsOrNot(server, 'avatars', basename(avatarPath), false)
})
})
})
after(async function () {

View File

@ -3,7 +3,7 @@ import { remove, writeJSON } from 'fs-extra/esm'
import snakeCase from 'lodash-es/snakeCase.js'
import validator from 'validator'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { About, ActorImageType, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
import { objectConverter } from '../../helpers/core-utils.js'
import { CONFIG, reloadConfig } from '../../initializers/config.js'
@ -14,6 +14,7 @@ import {
authenticate,
ensureUserHasRight,
openapiOperationDoc,
updateAvatarValidator,
updateBannerValidator
} from '../../middlewares/index.js'
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
@ -63,18 +64,36 @@ configRouter.delete('/custom',
asyncMiddleware(deleteCustomConfig)
)
// ---------------------------------------------------------------------------
configRouter.post('/instance-banner/pick',
authenticate,
createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateBannerValidator,
asyncMiddleware(updateInstanceBanner)
asyncMiddleware(updateInstanceImageFactory(ActorImageType.BANNER))
)
configRouter.delete('/instance-banner',
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
asyncMiddleware(deleteInstanceBanner)
asyncMiddleware(deleteInstanceImageFactory(ActorImageType.BANNER))
)
// ---------------------------------------------------------------------------
configRouter.post('/instance-avatar/pick',
authenticate,
createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateAvatarValidator,
asyncMiddleware(updateInstanceImageFactory(ActorImageType.AVATAR))
)
configRouter.delete('/instance-avatar',
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
asyncMiddleware(deleteInstanceImageFactory(ActorImageType.AVATAR))
)
// ---------------------------------------------------------------------------
@ -86,7 +105,7 @@ async function getConfig (req: express.Request, res: express.Response) {
}
async function getAbout (req: express.Request, res: express.Response) {
const banners = await ActorImageModel.listByActor(await getServerActor(), ActorImageType.BANNER)
const { avatars, banners } = await ActorImageModel.listServerActorImages()
const about: About = {
instance: {
@ -107,7 +126,8 @@ async function getAbout (req: express.Request, res: express.Response) {
languages: CONFIG.INSTANCE.LANGUAGES,
categories: CONFIG.INSTANCE.CATEGORIES,
banners: banners.map(b => b.toFormattedJSON())
banners: banners.map(b => b.toFormattedJSON()),
avatars: avatars.map(a => a.toFormattedJSON())
}
}
@ -155,29 +175,47 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
return res.json(data)
}
async function updateInstanceBanner (req: express.Request, res: express.Response) {
const bannerPhysicalFile = req.files['bannerfile'][0]
// ---------------------------------------------------------------------------
const serverActor = await getServerActor()
serverActor.Banners = await ActorImageModel.listByActor(serverActor, ActorImageType.BANNER) // Reload banners from DB
function updateInstanceImageFactory (imageType: ActorImageType_Type) {
return async (req: express.Request, res: express.Response) => {
const field = imageType === ActorImageType.BANNER
? 'bannerfile'
: 'avatarfile'
await updateLocalActorImageFiles({
accountOrChannel: serverActor.Account,
imagePhysicalFile: bannerPhysicalFile,
type: ActorImageType.BANNER,
sendActorUpdate: false
})
const imagePhysicalFile = req.files[field][0]
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
await updateLocalActorImageFiles({
accountOrChannel: (await getServerActorWithUpdatedImages(imageType)).Account,
imagePhysicalFile,
type: imageType,
sendActorUpdate: false
})
ClientHtml.invalidateCache()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
}
async function deleteInstanceBanner (req: express.Request, res: express.Response) {
function deleteInstanceImageFactory (imageType: ActorImageType_Type) {
return async (req: express.Request, res: express.Response) => {
await deleteLocalActorImageFile((await getServerActorWithUpdatedImages(imageType)).Account, imageType)
ClientHtml.invalidateCache()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
}
async function getServerActorWithUpdatedImages (imageType: ActorImageType_Type) {
const serverActor = await getServerActor()
serverActor.Banners = await ActorImageModel.listByActor(serverActor, ActorImageType.BANNER) // Reload banners from DB
const updatedImages = await ActorImageModel.listByActor(serverActor, imageType) // Reload images from DB
await deleteLocalActorImageFile(serverActor.Account, ActorImageType.BANNER)
if (imageType === ActorImageType.BANNER) serverActor.Banners = updatedImages
else serverActor.Avatars = updatedImages
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
return serverActor
}
// ---------------------------------------------------------------------------

View File

@ -15,6 +15,7 @@ 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 { ActorImageModel } from '@server/models/actor/actor-image.js'
/**
*
@ -48,6 +49,8 @@ class ServerConfigManager {
const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
const { avatars, banners } = await ActorImageModel.listServerActorImages()
return {
client: {
videos: {
@ -100,7 +103,9 @@ class ServerConfigManager {
customizations: {
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
}
},
avatars: avatars.map(a => a.toFormattedJSON()),
banners: banners.map(b => b.toFormattedJSON())
},
search: {
remoteUri: {

View File

@ -20,6 +20,7 @@ import { CONFIG } from '../../initializers/config.js'
import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants.js'
import { SequelizeModel, buildSQLAttributes, throwIfNotValid } from '../shared/index.js'
import { ActorModel } from './actor.js'
import { getServerActor } from '../application/application.js'
@Table({
tableName: 'actorImage',
@ -123,6 +124,15 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
return ActorImageModel.findAll(query)
}
static async listServerActorImages () {
const serverActor = await getServerActor()
const promises = [ ActorImageType.AVATAR, ActorImageType.BANNER ].map(type => ActorImageModel.listByActor(serverActor, type))
const [ avatars, banners ] = await Promise.all(promises)
return { avatars, banners }
}
static getImageUrl (image: MActorImage) {
if (!image) return undefined

View File

@ -953,17 +953,8 @@ paths:
tags:
- Config
responses:
'200':
'204':
description: successful operation
content:
application/json:
schema:
type: object
properties:
banners:
type: array
items:
$ref: '#/components/schemas/ActorImage'
'413':
description: image file too large
headers:
@ -998,6 +989,51 @@ paths:
'204':
description: successful operation
/api/v1/config/instance-avatar/pick:
post:
summary: Update instance avatar
security:
- OAuth2:
- admin
tags:
- Config
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 avatar
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
avatarfile:
description: The file to upload.
type: string
format: binary
encoding:
avatarfile:
contentType: image/png, image/jpeg
'/api/v1/config/instance-avatar':
delete:
summary: Delete instance avatar
security:
- OAuth2:
- admin
tags:
- Config
responses:
'204':
description: successful operation
/api/v1/custom-pages/homepage/instance:
get:
summary: Get instance custom homepage
@ -8251,6 +8287,14 @@ components:
type: string
css:
type: string
avatars:
type: array
items:
$ref: '#/components/schemas/ActorImage'
banners:
type: array
items:
$ref: '#/components/schemas/ActorImage'
search:
type: object
properties:
@ -8613,6 +8657,36 @@ components:
type: string
terms:
type: string
codeOfConduct:
type: string
hardwareInformation:
type: string
creationReason:
type: string
moderationInformation:
type: string
administrator:
type: string
maintenanceLifetime:
type: string
businessModel:
type: string
languages:
type: array
items:
type: string
categories:
type: array
items:
type: integer
avatars:
type: array
items:
$ref: '#/components/schemas/ActorImage'
banners:
type: array
items:
$ref: '#/components/schemas/ActorImage'
ServerConfigCustom:
properties:
instance: