diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html index 34c8b62f4..853377246 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html @@ -8,11 +8,25 @@
+
+ + +
+

Square icon can be used on your custom homepage.

+
+ + +
+
-

Banner is displayed in the about, login and registration pages.

+

Banner is displayed in the about, login and registration pages and be used on your custom homepage.

It can also be displayed on external websites to promote your instance, such as JoinPeerTube.org.

diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts index bf5428b99..e370268aa 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts @@ -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() + }) + } + } diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html index 6464f0050..293cc2c32 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html @@ -70,10 +70,18 @@
-
NEW USER
-
- -
+ + @if (isCreation()) { +
NEW USER
+ } @else if (user) { +
+ +
+ } +
diff --git a/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html b/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html index ecf5c6a28..6ffeae1ba 100644 --- a/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html +++ b/client/src/app/+manage/video-channel-edit/video-channel-edit.component.html @@ -16,9 +16,10 @@ >
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html index 11ea7e326..ef60b1761 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html @@ -4,7 +4,11 @@
- +
diff --git a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html index c63b5b361..b4dae3f95 100644 --- a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html +++ b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.html @@ -1,6 +1,6 @@
- +
@@ -31,8 +31,8 @@
-
{{ actor.displayName }}
-
{{ actor.name }}
-
{{ actor.followersCount }} subscribers
+
{{ displayName }}
+
{{ username }}
+
{{ subscribers }} subscribers
diff --git a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts index fc925083e..3403d6192 100644 --- a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts +++ b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts @@ -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 - @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() @Output() avatarDelete = new EventEmitter() @@ -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 } } diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.html b/client/src/app/shared/shared-actor-image/actor-avatar.component.html index 011afef27..529297f66 100644 --- a/client/src/app/shared/shared-actor-image/actor-avatar.component.html +++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.html @@ -1,11 +1,11 @@ - + -
+
{{ getActorInitial() }}
-
+
diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.scss b/client/src/app/shared/shared-actor-image/actor-avatar.component.scss index 68bf74553..9eef52575 100644 --- a/client/src/app/shared/shared-actor-image/actor-avatar.component.scss +++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.scss @@ -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; } diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts index 36babbe34..e8e9b5018 100644 --- a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts +++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts @@ -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 () { diff --git a/client/src/app/shared/shared-custom-markup/custom-markup.service.ts b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts index 27592e24c..cbbc3a173 100644 --- a/client/src/app/shared/shared-custom-markup/custom-markup.service.ts +++ b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts @@ -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) diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/index.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/index.ts index 5db5d35fd..69495b02f 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/index.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/index.ts @@ -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' diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-avatar-markup.component.html b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-avatar-markup.component.html new file mode 100644 index 000000000..e0f43913e --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-avatar-markup.component.html @@ -0,0 +1 @@ + diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-avatar-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-avatar-markup.component.ts new file mode 100644 index 000000000..3edd270cf --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-avatar-markup.component.ts @@ -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() + } +} diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-banner-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-banner-markup.component.ts index 692817573..cf99f4d77 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-banner-markup.component.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-banner-markup.component.ts @@ -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() - 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() } } diff --git a/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts index 4b1ba1218..93db88f58 100644 --- a/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts +++ b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts @@ -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: [ diff --git a/client/src/app/shared/shared-instance/instance-banner.component.ts b/client/src/app/shared/shared-instance/instance-banner.component.ts index 16f6054b2..f15848f8c 100644 --- a/client/src/app/shared/shared-instance/instance-banner.component.ts +++ b/client/src/app/shared/shared-instance/instance-banner.component.ts @@ -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 } } diff --git a/client/src/app/shared/shared-instance/instance.service.ts b/client/src/app/shared/shared-instance/instance.service.ts index 2bbf8d3ba..23b4e0866 100644 --- a/client/src/app/shared/shared-instance/instance.service.ts +++ b/client/src/app/shared/shared-instance/instance.service.ts @@ -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(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, diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts index e0d9db311..325416156 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts @@ -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 diff --git a/packages/models/src/custom-markup/custom-markup-data.model.ts b/packages/models/src/custom-markup/custom-markup-data.model.ts index fd1897b9b..ba2924dcc 100644 --- a/packages/models/src/custom-markup/custom-markup-data.model.ts +++ b/packages/models/src/custom-markup/custom-markup-data.model.ts @@ -62,3 +62,7 @@ export type ContainerMarkupData = { export type InstanceBannerMarkupData = { revertHomePaddingTop?: StringBoolean // default to 'true' } + +export type InstanceAvatarMarkupData = { + size: string // size in pixels +} diff --git a/packages/models/src/server/about.model.ts b/packages/models/src/server/about.model.ts index 903e3030c..87c3e39a9 100644 --- a/packages/models/src/server/about.model.ts +++ b/packages/models/src/server/about.model.ts @@ -20,5 +20,6 @@ export interface About { categories: number[] banners: ActorImage[] + avatars: ActorImage[] } } diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts index cbd3ebddf..9106c5b95 100644 --- a/packages/models/src/server/server-config.model.ts +++ b/packages/models/src/server/server-config.model.ts @@ -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: { diff --git a/packages/server-commands/src/server/config-command.ts b/packages/server-commands/src/server/config-command.ts index ac2358abe..3a550eb55 100644 --- a/packages/server-commands/src/server/config-command.ts +++ b/packages/server-commands/src/server/config-command.ts @@ -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, diff --git a/packages/tests/src/api/check-params/config.ts b/packages/tests/src/api/check-params/config.ts index b5b282200..4e093ed26 100644 --- a/packages/tests/src/api/check-params/config.ts +++ b/packages/tests/src/api/check-params/config.ts @@ -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 }) + } }) }) diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts index 2b108a462..ac43943bc 100644 --- a/packages/tests/src/api/server/config.ts +++ b/packages/tests/src/api/server/config.ts @@ -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 () { diff --git a/server/core/controllers/api/config.ts b/server/core/controllers/api/config.ts index 881ab270f..3d9d9959b 100644 --- a/server/core/controllers/api/config.ts +++ b/server/core/controllers/api/config.ts @@ -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 } // --------------------------------------------------------------------------- diff --git a/server/core/lib/server-config-manager.ts b/server/core/lib/server-config-manager.ts index 977ede1fc..fe29d5baa 100644 --- a/server/core/lib/server-config-manager.ts +++ b/server/core/lib/server-config-manager.ts @@ -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: { diff --git a/server/core/models/actor/actor-image.ts b/server/core/models/actor/actor-image.ts index f54baadc1..dffd1d4a3 100644 --- a/server/core/models/actor/actor-image.ts +++ b/server/core/models/actor/actor-image.ts @@ -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 { 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 diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 8c9782839..200b037af 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -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: