diff --git a/client/angular.json b/client/angular.json index 80ceaaccf..a63a14e58 100644 --- a/client/angular.json +++ b/client/angular.json @@ -189,8 +189,7 @@ } }, "assets": [ - "src/assets/images", - "src/manifest.webmanifest" + "src/assets/images" ], "styles": [ "src/sass/application.scss" diff --git a/client/src/app/+admin/config/admin-config.component.ts b/client/src/app/+admin/config/admin-config.component.ts index a1919a71c..2b561fbd7 100644 --- a/client/src/app/+admin/config/admin-config.component.ts +++ b/client/src/app/+admin/config/admin-config.component.ts @@ -25,6 +25,11 @@ export class AdminConfigComponent implements OnInit { label: $localize`Information`, routerLink: 'information' }, + { + type: 'link', + label: $localize`Logo`, + routerLink: 'logo' + }, { type: 'link', label: $localize`General`, diff --git a/client/src/app/+admin/config/config.routes.ts b/client/src/app/+admin/config/config.routes.ts index fa2dc7cbf..e161b3c52 100644 --- a/client/src/app/+admin/config/config.routes.ts +++ b/client/src/app/+admin/config/config.routes.ts @@ -15,6 +15,8 @@ import { } from './pages' import { AdminConfigCustomizationComponent } from './pages/admin-config-customization.component' import { AdminConfigService } from '../../shared/shared-admin/admin-config.service' +import { AdminConfigLogoComponent } from './pages/admin-config-logo.component' +import { InstanceLogoService } from './shared/instance-logo.service' export const customConfigResolver: ResolveFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { return inject(AdminConfigService).getCustomConfig() @@ -51,6 +53,13 @@ export const commentPoliciesResolver: ResolveFn> = ( + _route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot +) => { + return inject(InstanceLogoService).getAllLogos() +} + export const configRoutes: Routes = [ { path: 'config', @@ -61,6 +70,9 @@ export const configRoutes: Routes = [ resolve: { customConfig: customConfigResolver }, + providers: [ + InstanceLogoService + ], component: AdminConfigComponent, children: [ { @@ -111,6 +123,19 @@ export const configRoutes: Routes = [ } } }, + { + path: 'logo', + component: AdminConfigLogoComponent, + canDeactivate: [ CanDeactivateGuard ], + resolve: { + logos: logosResolver + }, + data: { + meta: { + title: $localize`Platform logos` + } + } + }, { path: 'general', component: AdminConfigGeneralComponent, diff --git a/client/src/app/+admin/config/pages/admin-config-information.component.html b/client/src/app/+admin/config/pages/admin-config-information.component.html index de287e2f7..fea520521 100644 --- a/client/src/app/+admin/config/pages/admin-config-information.component.html +++ b/client/src/app/+admin/config/pages/admin-config-information.component.html @@ -38,34 +38,6 @@
-
- - -
-

Square icon can be used on your custom homepage.

-
- - -
- -
- - -
-

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/pages/admin-config-information.component.ts b/client/src/app/+admin/config/pages/admin-config-information.component.ts index daab24699..42670b73d 100644 --- a/client/src/app/+admin/config/pages/admin-config-information.component.ts +++ b/client/src/app/+admin/config/pages/admin-config-information.component.ts @@ -1,10 +1,8 @@ import { CommonModule } from '@angular/common' -import { HttpErrorResponse } from '@angular/common/http' -import { Component, OnInit, OnDestroy, inject } from '@angular/core' +import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { ActivatedRoute, RouterLink } from '@angular/router' -import { CanComponentDeactivate, Notifier, ServerService } from '@app/core' -import { genericUploadErrorHandler } from '@app/helpers' +import { CanComponentDeactivate, ServerService } from '@app/core' import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators' import { ADMIN_EMAIL_VALIDATOR, @@ -20,12 +18,9 @@ import { import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component' -import { InstanceService } from '@app/shared/shared-main/instance/instance.service' -import { maxBy } from '@peertube/peertube-core-utils' -import { ActorImage, CustomConfig, HTMLServerConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models' +import { ActorImage, CustomConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models' +import { Subscription } from 'rxjs' import { SelectOptionsItem } from 'src/types/select-options-item.model' -import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component' -import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component' import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service' import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component' import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component' @@ -34,7 +29,6 @@ import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/sel import { HelpComponent } from '../../../shared/shared-main/buttons/help.component' import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive' import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' -import { Subscription } from 'rxjs' type Form = { admin: FormGroup<{ @@ -84,8 +78,6 @@ type Form = { imports: [ FormsModule, ReactiveFormsModule, - ActorAvatarEditComponent, - ActorBannerEditComponent, SelectRadioComponent, CommonModule, CustomMarkupHelpComponent, @@ -100,8 +92,6 @@ type Form = { }) export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate { private customMarkup = inject(CustomMarkupService) - private notifier = inject(Notifier) - private instanceService = inject(InstanceService) private server = inject(ServerService) private route = inject(ActivatedRoute) private formReactiveService = inject(FormReactiveService) @@ -136,7 +126,6 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo } ] - private serverConfig: HTMLServerConfig private customConfig: CustomConfig private customConfigSub: Subscription @@ -155,9 +144,6 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id })) this.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id })) - this.serverConfig = this.server.getHTMLConfig() - - this.updateActorImages() this.buildForm() this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs() @@ -235,72 +221,6 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo return this.customMarkup.getCustomMarkdownRenderer() } - onBannerChange (formData: FormData) { - this.instanceService.updateInstanceBanner(formData) - .subscribe({ - next: () => { - this.notifier.success($localize`Banner changed.`) - - this.resetActorImages() - }, - - error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier }) - }) - } - - onBannerDelete () { - this.instanceService.deleteInstanceBanner() - .subscribe({ - next: () => { - this.notifier.success($localize`Banner deleted.`) - - this.resetActorImages() - }, - - error: err => this.notifier.error(err.message) - }) - } - - onAvatarChange (formData: FormData) { - this.instanceService.updateInstanceAvatar(formData) - .subscribe({ - next: () => { - this.notifier.success($localize`Avatar changed.`) - - this.resetActorImages() - }, - - error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier }) - }) - } - - onAvatarDelete () { - this.instanceService.deleteInstanceAvatar() - .subscribe({ - next: () => { - this.notifier.success($localize`Avatar deleted.`) - - this.resetActorImages() - }, - - error: err => this.notifier.error(err.message) - }) - } - - private updateActorImages () { - this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path - this.instanceAvatars = this.serverConfig.instance.avatars - } - - private resetActorImages () { - this.server.resetConfig() - .subscribe(config => { - this.serverConfig = config - - this.updateActorImages() - }) - } - save () { this.adminConfigService.saveAndUpdateCurrent({ currentConfig: this.customConfig, diff --git a/client/src/app/+admin/config/pages/admin-config-logo.component.html b/client/src/app/+admin/config/pages/admin-config-logo.component.html new file mode 100644 index 000000000..0dd05ffe1 --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-logo.component.html @@ -0,0 +1,129 @@ + + +
+
+
+

LOGO

+
+ +
+
+ + +
+

Square icon is used in the mobile application and can be used on your custom homepage.

+
+ + +
+ +
+ + +
+

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. +

+
+ + +
+ +
+ + +
+

+ Favicon is the icon displayed in web browser tab. + If not set, the Square icon will be used. +

+ +

It will be resized to 32x32 pixels and converted to .png format.

+
+ + +
+ +
+ + +
+

+ Logo displayed in the header on large screens such as desktop computers + If not set, the Square icon will be used. +

+ +

Its height will be reduced to 48 pixels and the width will be calculated based on the original file ratio.

+
+ + +
+ +
+ + +
Useful for example if your "Desktop header logo" already includes your platform name
+
+
+
+ +
+ + +
+

+ Logo displayed in the header on small screens such as mobile devices + If not set, the Square icon will be used. +

+ +

It will be resized to 48x48 pixels.

+
+ + +
+ +
+ + +
+

+ Default logo displayed on social media. + If not set, the Square icon will be used. +

+ +

It will be resized to 1200x650 pixels.

+
+ + +
+
+
+
diff --git a/client/src/app/+admin/config/pages/admin-config-logo.component.scss b/client/src/app/+admin/config/pages/admin-config-logo.component.scss new file mode 100644 index 000000000..02a7a2706 --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-logo.component.scss @@ -0,0 +1,30 @@ +@use "sass:math"; +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; + +.banner-preview { + max-width: 500px; + aspect-ratio: math.div(1, $banner-inverted-ratio); + width: 100%; + height: auto; +} + +.avatar-preview, +.header-square-preview { + width: 128px; + height: 128px; +} + +.header-wide-preview { + width: auto; + height: 128px; + max-width: 500px; +} + +.opengraph-preview { + max-width: 500px; + aspect-ratio: math.div(1200, 630); + width: 100%; + height: auto; +} diff --git a/client/src/app/+admin/config/pages/admin-config-logo.component.ts b/client/src/app/+admin/config/pages/admin-config-logo.component.ts new file mode 100644 index 000000000..4f3938231 --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-logo.component.ts @@ -0,0 +1,174 @@ +import { CommonModule } from '@angular/common' +import { Component, inject, OnDestroy, OnInit } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { ActivatedRoute } from '@angular/router' +import { CanComponentDeactivate, Notifier, ServerService } from '@app/core' +import { + BuildFormArgumentTyped, + FormReactiveErrorsTyped, + FormReactiveMessagesTyped +} from '@app/shared/form-validators/form-validator.model' +import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component' +import { CustomConfig, LogoType } from '@peertube/peertube-models' +import { of, Subscription, switchMap, tap } from 'rxjs' +import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service' +import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component' +import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' +import { InstanceLogoService } from '../shared/instance-logo.service' + +type Form = { + hideInstanceName: FormControl + + avatar: FormControl + banner: FormControl + favicon: FormControl + 'header-square': FormControl + 'header-wide': FormControl + opengraph: FormControl +} + +@Component({ + selector: 'my-admin-config-logo', + templateUrl: './admin-config-logo.component.html', + styleUrls: [ './admin-config-logo.component.scss', './admin-config-common.scss' ], + imports: [ + FormsModule, + ReactiveFormsModule, + CommonModule, + AdminSaveBarComponent, + PreviewUploadComponent, + PeertubeCheckboxComponent + ] +}) +export class AdminConfigLogoComponent implements OnInit, OnDestroy, CanComponentDeactivate { + private notifier = inject(Notifier) + private logoService = inject(InstanceLogoService) + private server = inject(ServerService) + private route = inject(ActivatedRoute) + private formReactiveService = inject(FormReactiveService) + private serverService = inject(ServerService) + private adminConfigService = inject(AdminConfigService) + + form: FormGroup
+ formErrors: FormReactiveErrorsTyped = {} + validationMessages: FormReactiveMessagesTyped = {} + + private customConfig: CustomConfig + private customConfigSub: Subscription + + get instanceName () { + return this.server.getHTMLConfig().instance.name + } + + ngOnInit () { + this.customConfig = this.route.parent.snapshot.data['customConfig'] + + this.buildForm() + + this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs() + .subscribe(customConfig => { + this.customConfig = customConfig + + this.form.patchValue({ hideInstanceName: customConfig.client.header.hideInstanceName }) + }) + } + + ngOnDestroy () { + if (this.customConfigSub) this.customConfigSub.unsubscribe() + } + + private buildForm () { + const obj: BuildFormArgumentTyped = { + 'hideInstanceName': null, + + 'avatar': null, + 'banner': null, + 'favicon': null, + 'header-square': null, + 'header-wide': null, + 'opengraph': null + } + + const defaultValues = { + hideInstanceName: this.customConfig.client.header.hideInstanceName, + + ...this.route.snapshot.data.logos + } + + const { + form, + formErrors, + validationMessages + } = this.formReactiveService.buildForm(obj, defaultValues) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + } + + canDeactivate () { + return { canDeactivate: !this.form.dirty } + } + + save () { + this.adminConfigService.updateCustomConfig({ + client: { + header: { + hideInstanceName: this.form.value.hideInstanceName + } + } + }).pipe( + switchMap(() => this.serverService.resetConfig()), + tap(newConfig => Object.assign(this.customConfig, newConfig)), + switchMap(() => this.buildSaveAvatar()), + switchMap(() => this.saveBanner()), + switchMap(() => this.saveLogo('favicon')), + switchMap(() => this.saveLogo('header-square')), + switchMap(() => this.saveLogo('header-wide')), + switchMap(() => this.saveLogo('opengraph')), + switchMap(() => this.serverService.resetConfig()), + switchMap(() => this.logoService.getAllLogos()) + ).subscribe({ + next: logos => { + this.notifier.success($localize`Logos updated`) + + this.form.patchValue(logos) + this.form.markAsPristine() + }, + + error: err => this.notifier.error(err.message) + }) + } + + private buildSaveAvatar () { + if (this.form.controls.avatar.pristine) return of(true) + + const avatar = this.form.value.avatar + + return avatar + ? this.logoService.updateAvatar(avatar) + : this.logoService.deleteAvatar() + } + + private saveBanner () { + if (this.form.controls.banner.pristine) return of(true) + + const banner = this.form.value.banner + + return banner + ? this.logoService.updateBanner(banner) + : this.logoService.deleteBanner() + } + + private saveLogo (type: LogoType) { + const control = this.form.get(type) + if (control.pristine) return of(true) + + const logo = control.value + + return logo + ? this.logoService.updateLogo(logo, type) + : this.logoService.deleteLogo(type) + } +} diff --git a/client/src/app/+admin/config/shared/instance-logo.service.ts b/client/src/app/+admin/config/shared/instance-logo.service.ts new file mode 100644 index 000000000..c68a376c2 --- /dev/null +++ b/client/src/app/+admin/config/shared/instance-logo.service.ts @@ -0,0 +1,120 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable, inject } from '@angular/core' +import { RestExtractor, ServerService } from '@app/core' +import { InstanceService } from '@app/shared/shared-main/instance/instance.service' +import { maxBy } from '@peertube/peertube-core-utils' +import { LogoType } from '@peertube/peertube-models' +import { logger } from '@root-helpers/logger' +import { catchError } from 'rxjs/operators' + +@Injectable() +export class InstanceLogoService { + private authHttp = inject(HttpClient) + private restExtractor = inject(RestExtractor) + private server = inject(ServerService) + + updateBanner (banner: Blob) { + const url = InstanceService.BASE_CONFIG_URL + '/instance-banner/pick' + + const formData = new FormData() + formData.append('bannerfile', banner) + + return this.authHttp.post(url, formData) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + deleteBanner () { + const url = InstanceService.BASE_CONFIG_URL + '/instance-banner' + + return this.authHttp.delete(url) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + // --------------------------------------------------------------------------- + + updateAvatar (avatar: Blob) { + const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar/pick' + + const formData = new FormData() + formData.append('avatarfile', avatar) + + return this.authHttp.post(url, formData) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + deleteAvatar () { + const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar' + + return this.authHttp.delete(url) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + // --------------------------------------------------------------------------- + + updateLogo (logo: Blob, type: LogoType) { + const url = InstanceService.BASE_CONFIG_URL + '/instance-logo/' + type + '/pick' + + const formData = new FormData() + formData.append('logofile', logo) + + return this.authHttp.post(url, formData) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + deleteLogo (type: LogoType) { + const url = InstanceService.BASE_CONFIG_URL + '/instance-logo/' + type + + return this.authHttp.delete(url) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + // --------------------------------------------------------------------------- + + async getAllLogos () { + const config = this.server.getHTMLConfig() + + const promises: Promise[] = [] + const result: Partial> = {} + const logoTypes: LogoType[] = [ 'favicon', 'header-square', 'header-wide', 'opengraph' ] + + const fetchLogo = (fileUrl: string, type: keyof typeof result) => { + return fetch(fileUrl) + .then(response => response.blob()) + .then(blob => result[type] = blob) + .catch(() => { + result[type] = null + logger.error('Could not fetch logo of type: ' + type) + }) + } + + for (const type of logoTypes) { + const logo = maxBy(config.instance.logo.filter(l => l.type === type), 'width') + if (!logo || logo.isFallback === true) { + result[type] = null + continue + } + + const p = fetchLogo(logo.fileUrl, type) + + promises.push(p) + } + + const avatarFileUrl = maxBy(config.instance.avatars, 'width')?.fileUrl + if (!avatarFileUrl) { + result.avatar = null + } else { + promises.push(fetchLogo(avatarFileUrl, 'avatar')) + } + + const bannerFileUrl = maxBy(config.instance.banners, 'width')?.fileUrl + if (!bannerFileUrl) { + result.banner = null + } else { + promises.push(fetchLogo(bannerFileUrl, 'banner')) + } + + await Promise.all(promises) + + return result + } +} diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html index 221a70902..317af8ff6 100644 --- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html +++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html @@ -35,10 +35,7 @@
- +
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss index 71b6107a2..74b497f56 100644 --- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss +++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss @@ -1,8 +1,8 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use '_form-mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; -input[type=text] { +input[type="text"] { @include peertube-input-text(340px); } @@ -17,5 +17,10 @@ my-select-options { } .content-col { - max-width: 500px;; + max-width: 500px; +} + +my-preview-upload { + width: 223px; + height: 122px; } diff --git a/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.html b/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.html index b1acf10d3..f3bdc3660 100644 --- a/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.html +++ b/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.html @@ -31,10 +31,7 @@ The chosen image will be definitive and cannot be modified.
- +
diff --git a/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.scss b/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.scss index ed817bff7..9e888e79e 100644 --- a/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.scss +++ b/client/src/app/+videos-publish-manage/+video-publish/upload/video-upload.component.scss @@ -1,5 +1,5 @@ -@use '_variables' as *; -@use '_mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; .first-step-block { .form-group-channel { @@ -13,5 +13,10 @@ .audio-preview { margin: 30px 0; + + my-preview-upload { + width: 360px; + height: 200px; + } } } diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index c0fdc9e94..8730dd3ba 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html @@ -11,8 +11,11 @@
- - {{ instanceName }} + + + @if (isInstanceNameDisplayed()) { + {{ instanceName }} + } diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss index aec81ec31..a7981df69 100644 --- a/client/src/app/header/header.component.scss +++ b/client/src/app/header/header.component.scss @@ -61,15 +61,10 @@ @include margin-left(10px); } -.icon-logo { +.logo { display: inline-block; - width: var(--co-logo-size); - height: var(--co-logo-size); min-width: var(--co-logo-size); - max-width: var(--co-logo-size); - - background-repeat: no-repeat; - background-size: contain; + height: var(--co-logo-size); } my-search-typeahead { diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts index 57d4b156b..ff191b26d 100644 --- a/client/src/app/header/header.component.ts +++ b/client/src/app/header/header.component.ts @@ -19,6 +19,7 @@ import { GlobalIconComponent } from '../shared/shared-icons/global-icon.componen import { ButtonComponent } from '../shared/shared-main/buttons/button.component' import { SearchTypeaheadComponent } from './search-typeahead.component' import { HeaderService } from './header.service' +import { findAppropriateImage } from '@peertube/peertube-core-utils' @Component({ selector: 'my-header', @@ -92,6 +93,10 @@ export class HeaderComponent implements OnInit, OnDestroy { return this.serverService.getHTMLConfig().instance.name } + isInstanceNameDisplayed () { + return this.serverService.getHTMLConfig().client.header.hideInstanceName !== true + } + isLoaded () { return this.config && (!this.loggedIn || !!this.user?.account) } @@ -104,6 +109,16 @@ export class HeaderComponent implements OnInit, OnDestroy { return this.screenService.isInSmallView() } + getLogoUrl () { + const logos = this.serverService.getHTMLConfig().instance.logo + + if (this.isInMobileView()) { + return findAppropriateImage(logos.filter(l => l.type === 'header-square'), 36)?.fileUrl + } + + return findAppropriateImage(logos.filter(l => l.type === 'header-wide'), 36)?.fileUrl + } + ngOnInit () { this.htmlConfig = this.serverService.getHTMLConfig() this.currentInterfaceLanguage = this.languageChooserModal().getCurrentLanguage() diff --git a/client/src/app/shared/shared-forms/preview-upload.component.html b/client/src/app/shared/shared-forms/preview-upload.component.html index 05c12eee8..266053458 100644 --- a/client/src/app/shared/shared-forms/preview-upload.component.html +++ b/client/src/app/shared/shared-forms/preview-upload.component.html @@ -1,11 +1,27 @@ -
-
+
+ @if (imageSrc) { + Preview + } @else { +
+ } + +
- Preview -
+ @if (displayDelete() && file) { + + }
diff --git a/client/src/app/shared/shared-forms/preview-upload.component.scss b/client/src/app/shared/shared-forms/preview-upload.component.scss index dea5f7921..dbd63b1c5 100644 --- a/client/src/app/shared/shared-forms/preview-upload.component.scss +++ b/client/src/app/shared/shared-forms/preview-upload.component.scss @@ -1,29 +1,54 @@ -@use '_variables' as *; -@use '_mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; -.root { - height: auto; - display: flex; - flex-direction: column; +:host { + display: block; +} - .preview-container { - position: relative; +.preview-container, +.preview { + height: 100%; + width: 100%; +} - my-reactive-file { - position: absolute; - bottom: 10px; - left: 10px; - } +.preview-container { + position: relative; - .preview { - object-fit: cover; - border-radius: 4px; - max-width: 100%; + .buttons { + display: flex; + gap: 10px; + } - &.no-image { - border: 2px solid #808080; - background-color: pvar(--bg); - } - } + .preview { + object-fit: cover; + border-radius: 4px; + max-width: 100%; + } +} + +.preview-container:not(.buttons-aside) { + .buttons { + position: absolute; + bottom: 10px; + left: 10px; + flex-wrap: wrap; + } + + .preview.no-image { + border: 2px solid pvar(--secondary-icon-color); + background-color: pvar(--bg); + } +} + +.preview-container.buttons-aside { + text-align: center; + border: 1px solid pvar(--secondary-icon-color); + border-radius: 4px; + width: min-content; + + @include padding(1rem, 1.5rem); + + .buttons { + margin-top: 1rem; } } diff --git a/client/src/app/shared/shared-forms/preview-upload.component.ts b/client/src/app/shared/shared-forms/preview-upload.component.ts index a3e83bd1d..2cfcf1fc6 100644 --- a/client/src/app/shared/shared-forms/preview-upload.component.ts +++ b/client/src/app/shared/shared-forms/preview-upload.component.ts @@ -1,11 +1,12 @@ -import { Component, forwardRef, OnInit, inject, input } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { CommonModule } from '@angular/common' +import { booleanAttribute, Component, forwardRef, inject, input, OnInit } from '@angular/core' +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms' import { ServerService } from '@app/core' -import { imageToDataURL } from '@root-helpers/images' import { HTMLServerConfig } from '@peertube/peertube-models' -import { NgIf, NgStyle } from '@angular/common' -import { ReactiveFileComponent } from './reactive-file.component' +import { imageToDataURL } from '@root-helpers/images' import { BytesPipe } from '../shared-main/common/bytes.pipe' +import { ReactiveFileComponent } from './reactive-file.component' +import { DeleteButtonComponent } from '../shared-main/buttons/delete-button.component' @Component({ selector: 'my-preview-upload', @@ -18,23 +19,24 @@ import { BytesPipe } from '../shared-main/common/bytes.pipe' multi: true } ], - imports: [ ReactiveFileComponent, NgIf, NgStyle ] + imports: [ CommonModule, FormsModule, ReactiveFileComponent, DeleteButtonComponent ] }) export class PreviewUploadComponent implements OnInit, ControlValueAccessor { private serverService = inject(ServerService) + readonly inputName = input.required() readonly inputLabel = input(undefined) - readonly inputName = input(undefined) - readonly previewWidth = input(undefined) - readonly previewHeight = input(undefined) + readonly displayDelete = input(false, { transform: booleanAttribute }) + readonly buttonsAside = input(false, { transform: booleanAttribute }) + readonly previewSize = input<{ width: string, height: string }>(undefined) imageSrc: string allowedExtensionsMessage = '' maxSizeText: string + file: Blob private serverConfig: HTMLServerConfig private bytesPipe: BytesPipe - private file: Blob constructor () { this.bytesPipe = new BytesPipe() @@ -90,6 +92,8 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor { private updatePreview () { if (this.file) { imageToDataURL(this.file).then(result => this.imageSrc = result) + } else { + this.imageSrc = undefined } } } diff --git a/client/src/app/shared/shared-forms/reactive-file.component.html b/client/src/app/shared/shared-forms/reactive-file.component.html index 5db05b0ca..235371e6c 100644 --- a/client/src/app/shared/shared-forms/reactive-file.component.html +++ b/client/src/app/shared/shared-forms/reactive-file.component.html @@ -13,7 +13,7 @@
{{ filename }}
-
diff --git a/client/src/app/shared/shared-forms/reactive-file.component.ts b/client/src/app/shared/shared-forms/reactive-file.component.ts index 43fe1866c..36fa06be0 100644 --- a/client/src/app/shared/shared-forms/reactive-file.component.ts +++ b/client/src/app/shared/shared-forms/reactive-file.component.ts @@ -1,10 +1,10 @@ -import { Component, forwardRef, OnChanges, OnInit, inject, input, output } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms' +import { CommonModule } from '@angular/common' +import { Component, forwardRef, inject, input, OnChanges, OnInit, output } from '@angular/core' +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms' import { Notifier } from '@app/core' import { GlobalIconName } from '@app/shared/shared-icons/global-icon.component' +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { GlobalIconComponent } from '../shared-icons/global-icon.component' -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' -import { NgClass, NgIf } from '@angular/common' @Component({ selector: 'my-reactive-file', @@ -17,7 +17,7 @@ import { NgClass, NgIf } from '@angular/common' multi: true } ], - imports: [ NgClass, NgbTooltip, NgIf, GlobalIconComponent, FormsModule ] + imports: [ CommonModule, NgbTooltipModule, GlobalIconComponent, FormsModule ] }) export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAccessor { private notifier = inject(Notifier) @@ -39,8 +39,7 @@ export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAcc classes: { [id: string]: boolean } = {} allowedExtensionsMessage = '' fileInputValue: any - - private file: File + file: File get filename () { if (!this.file) return '' @@ -62,7 +61,8 @@ export class ReactiveFileComponent implements OnInit, OnChanges, ControlValueAcc this.classes = { 'with-icon': !!this.icon(), 'primary-button': this.theme() === 'primary', - 'secondary-button': this.theme() === 'secondary' + 'secondary-button': this.theme() === 'secondary', + 'icon-only': !this.inputLabel() } } diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts index e300dabcc..90cf270e7 100644 --- a/client/src/app/shared/shared-main/account/actor.model.ts +++ b/client/src/app/shared/shared-main/account/actor.model.ts @@ -22,7 +22,7 @@ export abstract class Actor implements ServerActor { const avatar = size && avatarsAscWidth.length > 1 ? avatarsAscWidth.find(a => a.width >= size) - : avatarsAscWidth[avatarsAscWidth.length - 1] // Bigger one + : avatarsAscWidth[avatarsAscWidth.length - 1] // Biggest one if (!avatar) return '' if (avatar.fileUrl) return avatar.fileUrl diff --git a/client/src/app/shared/shared-main/buttons/button.component.ts b/client/src/app/shared/shared-main/buttons/button.component.ts index 4cfc88b09..73bd766eb 100644 --- a/client/src/app/shared/shared-main/buttons/button.component.ts +++ b/client/src/app/shared/shared-main/buttons/button.component.ts @@ -23,6 +23,8 @@ import { LoaderComponent } from '../common/loader.component' const debugLogger = debug('peertube:button') +export type ButtonTheme = 'primary' | 'secondary' | 'tertiary' | 'danger' + @Component({ selector: 'my-button', styleUrls: [ './button.component.scss' ], @@ -44,7 +46,7 @@ export class ButtonComponent implements OnChanges, AfterViewInit { private cd = inject(ChangeDetectorRef) readonly label = input('') - readonly theme = input<'primary' | 'secondary' | 'tertiary'>('secondary') + readonly theme = input('secondary') readonly icon = input(undefined) readonly href = input(undefined) @@ -101,6 +103,7 @@ export class ButtonComponent implements OnChanges, AfterViewInit { 'primary-button': this.theme() === 'primary', 'secondary-button': this.theme() === 'secondary', 'tertiary-button': this.theme() === 'tertiary', + 'danger-button': this.theme() === 'danger', 'has-icon': !!this.icon(), 'rounded-icon-button': !!this.rounded(), 'icon-only': !label, diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.ts b/client/src/app/shared/shared-main/buttons/delete-button.component.ts index 13a911f27..b52c6ef3a 100644 --- a/client/src/app/shared/shared-main/buttons/delete-button.component.ts +++ b/client/src/app/shared/shared-main/buttons/delete-button.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, OnChanges, input, model } from '@angular/core' -import { ButtonComponent } from './button.component' +import { ButtonComponent, ButtonTheme } from './button.component' @Component({ selector: 'my-delete-button', @@ -7,7 +7,7 @@ import { ButtonComponent } from './button.component' `, changeDetection: ChangeDetectionStrategy.OnPush, @@ -18,6 +18,7 @@ export class DeleteButtonComponent implements OnChanges { readonly title = model(undefined) readonly responsiveLabel = input(false) readonly disabled = input(undefined) + readonly theme = input('secondary') ngOnChanges () { const label = this.label() diff --git a/client/src/app/shared/shared-main/instance/instance.service.ts b/client/src/app/shared/shared-main/instance/instance.service.ts index a9bc48535..d46a57178 100644 --- a/client/src/app/shared/shared-main/instance/instance.service.ts +++ b/client/src/app/shared/shared-main/instance/instance.service.ts @@ -1,12 +1,12 @@ -import { forkJoin } from 'rxjs' -import { catchError, map } from 'rxjs/operators' import { HttpClient } from '@angular/common/http' import { Injectable, inject } from '@angular/core' import { MarkdownService, RestExtractor, ServerService } from '@app/core' import { objectKeysTyped, peertubeTranslate } from '@peertube/peertube-core-utils' import { About } from '@peertube/peertube-models' -import { environment } from '../../../../environments/environment' import { logger } from '@root-helpers/logger' +import { forkJoin } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { environment } from '../../../../environments/environment' export type AboutHTML = Pick< About['instance'], @@ -27,8 +27,8 @@ export class InstanceService { private markdownService = inject(MarkdownService) private serverService = inject(ServerService) - private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config' - private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server' + static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config' + static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server' getAbout () { return this.authHttp.get(InstanceService.BASE_CONFIG_URL + '/about') @@ -37,38 +37,6 @@ export class InstanceService { // --------------------------------------------------------------------------- - updateInstanceBanner (formData: FormData) { - const url = InstanceService.BASE_CONFIG_URL + '/instance-banner/pick' - - return this.authHttp.post(url, formData) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - deleteInstanceBanner () { - const url = InstanceService.BASE_CONFIG_URL + '/instance-banner' - - return this.authHttp.delete(url) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - // --------------------------------------------------------------------------- - - updateInstanceAvatar (formData: FormData) { - const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar/pick' - - return this.authHttp.post(url, formData) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - deleteInstanceAvatar () { - const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar' - - return this.authHttp.delete(url) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - // --------------------------------------------------------------------------- - contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) { const body = { fromEmail, diff --git a/client/src/index.html b/client/src/index.html index bba157f3f..7600b6e93 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -9,17 +9,6 @@ - - - - - - - diff --git a/client/src/manifest.webmanifest b/client/src/manifest.webmanifest deleted file mode 100644 index 0db219c0b..000000000 --- a/client/src/manifest.webmanifest +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "PeerTube", - "short_name": "PeerTube", - "start_url": "/", - "background_color": "#fff", - "theme_color": "#fff", - "description": "A federated video streaming platform using P2P", - "display": "standalone", - "icons": [ - { - "src": "/client/assets/images/icons/icon-36x36.png", - "sizes": "36x36", - "type": "image/png" - }, - { - "src": "/client/assets/images/icons/icon-48x48.png", - "sizes": "48x48", - "type": "image/png" - }, - { - "src": "/client/assets/images/icons/icon-72x72.png", - "sizes": "72x72", - "type": "image/png" - }, - { - "src": "/client/assets/images/icons/icon-96x96.png", - "sizes": "96x96", - "type": "image/png" - }, - { - "src": "/client/assets/images/icons/icon-144x144.png", - "sizes": "144x144", - "type": "image/png" - }, - { - "src": "/client/assets/images/icons/icon-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/client/assets/images/icons/icon-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} diff --git a/client/src/ngsw-config.json b/client/src/ngsw-config.json index af7554c00..4d987ef87 100644 --- a/client/src/ngsw-config.json +++ b/client/src/ngsw-config.json @@ -8,7 +8,6 @@ "resources": { "files": [ "/index.html", - "/client/assets/images/icons/favicon.png", "/client/*.css", "/client/*.js", "/manifest.webmanifest" diff --git a/client/src/sass/include/_button-mixins.scss b/client/src/sass/include/_button-mixins.scss index 06a6355cc..1e93dcfc1 100644 --- a/client/src/sass/include/_button-mixins.scss +++ b/client/src/sass/include/_button-mixins.scss @@ -1,9 +1,9 @@ -@use 'sass:math'; -@use 'sass:color'; -@use '_variables' as *; -@use '_mixins' as *; +@use "sass:math"; +@use "sass:color"; +@use "_variables" as *; +@use "_mixins" as *; -@mixin secondary-button ( +@mixin secondary-button( $fg: inherit, $active-bg: pvar(--bg-secondary-500), $hover-bg: pvar(--bg-secondary-450), @@ -48,7 +48,6 @@ } } - @mixin primary-button { @include button-focus(pvar(--primary-350)); @@ -148,7 +147,7 @@ @mixin danger-button { background-color: pvar(--input-danger-bg); color: pvar(--input-danger-fg); - border: 0; + border: 1px solid pvar(--input-danger-bg); @include button-focus(pvar(--input-danger-bg)); @@ -156,7 +155,7 @@ &:active, &.active, &:focus:not(:focus-visible) { - opacity: 0.8; + border-color: pvar(--input-danger-fg); } &[disabled] { @@ -185,7 +184,7 @@ padding: pvar(--input-y-padding) 8px; } - &:is(input[type=button]) { + &:is(input[type="button"]) { // Because of primeng that redefines border-radius of all input[type="..."] border-radius: pvar(--input-border-radius) !important; } @@ -250,7 +249,7 @@ overflow: hidden; display: inline-block; - input[type=file] { + input[type="file"] { position: absolute; top: 0; right: 0; diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html index dc3adf27d..b57f689d8 100644 --- a/client/src/standalone/videos/embed.html +++ b/client/src/standalone/videos/embed.html @@ -7,6 +7,7 @@ + @@ -14,8 +15,6 @@ - - diff --git a/config/default.yaml b/config/default.yaml index 46a3f970e..97abcf289 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -134,14 +134,15 @@ storage: cache: 'storage/cache/' plugins: 'storage/plugins/' well_known: 'storage/well-known/' + # Various admin/user uploads that are not suitable for the folders above + uploads: 'storage/uploads/' # Overridable client files in client/dist/assets/images: - # - logo.svg - # - favicon.png - # - default-playlist.jpg + # - default-avatar-account-48x48.png # - default-avatar-account.png + # - default-avatar-video-channel-48x48.png # - default-avatar-video-channel.png - # - and icons/*.png (PWA) - # Could contain for example assets/images/favicon.png + # - default-playlist.jpg + # Could contain for example "assets/images/default-playlist.jpg" # If the file exists, peertube will serve it # If not, peertube will fallback to the default file client_overrides: 'storage/client-overrides/' @@ -797,7 +798,7 @@ import: # * https://yt-dl.org/downloads/latest/youtube-dl # # You can also use a youtube-dl standalone binary (requires python_path: null) - # GNU/Linux binaries with support for impersonating browser requests (required by some i such as Vimeo) examples: + # GNU/Linux binaries with support for impersonating browser requests (required by some platforms such as Vimeo) examples: # * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux (x64) # * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_armv7l (ARMv7) # * https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_armv7l (ARMv8/AArch64/ARM64) @@ -1060,6 +1061,11 @@ search: # PeerTube client/interface configuration client: + header: + # Hide the instance name in the header on desktop + # Useful if your logo already contains the instance name + hide_instance_name: false + videos: miniature: # By default PeerTube client displays author username diff --git a/config/production.yaml.example b/config/production.yaml.example index 97e15692d..89091a7c8 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -132,14 +132,15 @@ storage: cache: '/var/www/peertube/storage/cache/' plugins: '/var/www/peertube/storage/plugins/' well_known: '/var/www/peertube/storage/well-known/' + # Various admin/user uploads that are not suitable for the folders above + uploads: '/var/www/peertube/storage/uploads/' # Overridable client files in client/dist/assets/images: - # - logo.svg - # - favicon.png - # - default-playlist.jpg + # - default-avatar-account-48x48.png # - default-avatar-account.png + # - default-avatar-video-channel-48x48.png # - default-avatar-video-channel.png - # - and icons/*.png (PWA) - # Could contain for example assets/images/favicon.png + # - default-playlist.jpg + # Could contain for example "assets/images/default-playlist.jpg" # If the file exists, peertube will serve it # If not, peertube will fallback to the default file client_overrides: '/var/www/peertube/storage/client-overrides/' @@ -1070,6 +1071,11 @@ search: # PeerTube client/interface configuration client: + header: + # Hide the instance name in the header on desktop + # Useful if your logo already contains the instance name + hide_instance_name: false + videos: miniature: # By default PeerTube client displays author username @@ -1153,3 +1159,8 @@ email: subject: # Support {{instanceName}} template variable prefix: '[{{instanceName}}] ' + +video_comments: + # Accept or not comments from remote instances + # This setting is not retroactive: current remote comments of your instance will not be affected + accept_remote_comments: true diff --git a/config/test-1.yaml b/config/test-1.yaml index 4207d5b33..2f01a3219 100644 --- a/config/test-1.yaml +++ b/config/test-1.yaml @@ -26,6 +26,7 @@ storage: cache: 'test1/cache/' plugins: 'test1/plugins/' well_known: 'test1/well-known/' + uploads: 'test1/uploads/' client_overrides: 'test1/client-overrides/' admin: diff --git a/config/test-2.yaml b/config/test-2.yaml index 1c0da1b05..0b93e7eea 100644 --- a/config/test-2.yaml +++ b/config/test-2.yaml @@ -26,6 +26,7 @@ storage: cache: 'test2/cache/' plugins: 'test2/plugins/' well_known: 'test2/well-known/' + uploads: 'test2/uploads/' client_overrides: 'test2/client-overrides/' admin: diff --git a/config/test-3.yaml b/config/test-3.yaml index 841398c4d..bcef3ceeb 100644 --- a/config/test-3.yaml +++ b/config/test-3.yaml @@ -26,6 +26,7 @@ storage: cache: 'test3/cache/' plugins: 'test3/plugins/' well_known: 'test3/well-known/' + uploads: 'test3/uploads/' client_overrides: 'test3/client-overrides/' admin: diff --git a/config/test-4.yaml b/config/test-4.yaml index 442b4ee28..54c36422f 100644 --- a/config/test-4.yaml +++ b/config/test-4.yaml @@ -26,6 +26,7 @@ storage: cache: 'test4/cache/' plugins: 'test4/plugins/' well_known: 'test4/well-known/' + uploads: 'test4/uploads/' client_overrides: 'test4/client-overrides/' admin: diff --git a/config/test-5.yaml b/config/test-5.yaml index 8a714520e..5c0bb26fa 100644 --- a/config/test-5.yaml +++ b/config/test-5.yaml @@ -26,6 +26,7 @@ storage: cache: 'test5/cache/' plugins: 'test5/plugins/' well_known: 'test5/well-known/' + uploads: 'test5/uploads/' client_overrides: 'test5/client-overrides/' admin: diff --git a/config/test-6.yaml b/config/test-6.yaml index 891c4c53d..29caa8e7a 100644 --- a/config/test-6.yaml +++ b/config/test-6.yaml @@ -26,6 +26,7 @@ storage: cache: 'test6/cache/' plugins: 'test6/plugins/' well_known: 'test6/well-known/' + uploads: 'test6/uploads/' client_overrides: 'test6/client-overrides/' admin: diff --git a/packages/core-utils/src/common/image.ts b/packages/core-utils/src/common/image.ts new file mode 100644 index 000000000..f229d46d8 --- /dev/null +++ b/packages/core-utils/src/common/image.ts @@ -0,0 +1,11 @@ +export function findAppropriateImage (images: T[], wantedWidth: number) { + const imagesSorted = images.sort((a, b) => a.width - b.width) + + for (const image of imagesSorted) { + if (image.width >= wantedWidth) { + return image + } + } + + return images[images.length - 1] // Biggest one +} diff --git a/packages/core-utils/src/common/index.ts b/packages/core-utils/src/common/index.ts index d7d8599aa..3b488034a 100644 --- a/packages/core-utils/src/common/index.ts +++ b/packages/core-utils/src/common/index.ts @@ -1,6 +1,7 @@ export * from './array.js' export * from './random.js' export * from './date.js' +export * from './image.js' export * from './number.js' export * from './object.js' export * from './regexp.js' diff --git a/packages/ffmpeg/src/ffmpeg-images.ts b/packages/ffmpeg/src/ffmpeg-images.ts index c266988e5..77c780bde 100644 --- a/packages/ffmpeg/src/ffmpeg-images.ts +++ b/packages/ffmpeg/src/ffmpeg-images.ts @@ -19,7 +19,7 @@ export class FFmpegImage { const command = this.commandWrapper.buildCommand(path) - if (newSize) command.size(`${newSize.width}x${newSize.height}`) + if (newSize) command.size(`${newSize.width ?? '?'}x${newSize.height ?? '?'}`) command.output(destination) diff --git a/packages/models/src/actors/actor-image.model.ts b/packages/models/src/actors/actor-image.model.ts index b2b684a53..74f607d09 100644 --- a/packages/models/src/actors/actor-image.model.ts +++ b/packages/models/src/actors/actor-image.model.ts @@ -1,4 +1,5 @@ export interface ActorImage { + height: number width: number // TODO: remove, deprecated in 7.1 diff --git a/packages/models/src/server/custom-config.model.ts b/packages/models/src/server/custom-config.model.ts index b9eda6ec1..21f0b1fef 100644 --- a/packages/models/src/server/custom-config.model.ts +++ b/packages/models/src/server/custom-config.model.ts @@ -79,6 +79,10 @@ export interface CustomConfig { } client: { + header: { + hideInstanceName: boolean + } + videos: { miniature: { preferAuthorDisplayName: boolean diff --git a/packages/models/src/server/index.ts b/packages/models/src/server/index.ts index ba6af8f6f..67ff80f24 100644 --- a/packages/models/src/server/index.ts +++ b/packages/models/src/server/index.ts @@ -6,7 +6,9 @@ export * from './contact-form.model.js' export * from './custom-config.model.js' export * from './debug.model.js' export * from './emailer.model.js' +export * from './upload-image.type.js' export * from './job.model.js' +export * from './logo-type.type.js' export * from './peertube-problem-document.model.js' export * from './server-config.model.js' export * from './server-debug.model.js' diff --git a/packages/models/src/server/logo-type.type.ts b/packages/models/src/server/logo-type.type.ts new file mode 100644 index 000000000..58d84164c --- /dev/null +++ b/packages/models/src/server/logo-type.type.ts @@ -0,0 +1 @@ +export type LogoType = 'favicon' | 'header-wide' | 'header-square' | 'opengraph' diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts index 1fa533e27..e1e9352f3 100644 --- a/packages/models/src/server/server-config.model.ts +++ b/packages/models/src/server/server-config.model.ts @@ -1,4 +1,4 @@ -import { ActorImage, VideoCommentPolicyType } from '../index.js' +import { ActorImage, LogoType, VideoCommentPolicyType } from '../index.js' import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js' import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { VideoPrivacyType } from '../videos/video-privacy.enum.js' @@ -37,6 +37,10 @@ export interface ServerConfig { serverCommit?: string client: { + header: { + hideInstanceName: boolean + } + videos: { miniature: { preferAuthorDisplayName: boolean @@ -132,6 +136,14 @@ export interface ServerConfig { avatars: ActorImage[] banners: ActorImage[] + + logo: { + type: LogoType + width: number + height: number + fileUrl: string + isFallback: boolean + }[] } search: { diff --git a/packages/models/src/server/upload-image.type.ts b/packages/models/src/server/upload-image.type.ts new file mode 100644 index 000000000..d9d29ba07 --- /dev/null +++ b/packages/models/src/server/upload-image.type.ts @@ -0,0 +1,8 @@ +export const UploadImageType = { + INSTANCE_FAVICON: 1, + INSTANCE_HEADER_WIDE: 2, + INSTANCE_HEADER_SQUARE: 3, + INSTANCE_OPENGRAPH: 4 +} as const + +export type UploadImageType_Type = typeof UploadImageType[keyof typeof UploadImageType] diff --git a/packages/server-commands/src/server/config-command.ts b/packages/server-commands/src/server/config-command.ts index 3c55ee4ec..850daa336 100644 --- a/packages/server-commands/src/server/config-command.ts +++ b/packages/server-commands/src/server/config-command.ts @@ -1,4 +1,4 @@ -import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models' +import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, LogoType, ServerConfig } from '@peertube/peertube-models' import { DeepPartial } from '@peertube/peertube-typescript-utils' import merge from 'lodash-es/merge.js' import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js' @@ -539,6 +539,45 @@ export class ConfigCommand extends AbstractCommand { // --------------------------------------------------------------------------- + updateInstanceLogo ( + options: OverrideCommandOptions & { + fixture: string + type: LogoType + } + ) { + const { fixture, type } = options + + return this.updateImageRequest({ + ...options, + + path: '/api/v1/config/instance-logo/' + type + '/pick', + fixture, + fieldname: 'logofile', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + deleteInstanceLogo ( + options: OverrideCommandOptions & { + type: LogoType + } + ) { + const { type } = options + + return this.deleteRequest({ + ...options, + + path: '/api/v1/config/instance-logo/' + type, + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + getCustomConfig (options: OverrideCommandOptions = {}) { const path = '/api/v1/config/custom' diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts index a30ba6ecf..a8ebdf682 100644 --- a/packages/server-commands/src/server/server.ts +++ b/packages/server-commands/src/server/server.ts @@ -65,7 +65,7 @@ export type RunServerOptions = { hideLogs?: boolean nodeArgs?: string[] peertubeArgs?: string[] - env?: { [ id: string ]: string } + env?: { [id: string]: string } } export class PeerTubeServer { @@ -400,6 +400,7 @@ export class PeerTubeServer { captions: this.getDirectoryPath('captions') + '/', cache: this.getDirectoryPath('cache') + '/', plugins: this.getDirectoryPath('plugins') + '/', + uploads: this.getDirectoryPath('uploads') + '/', well_known: this.getDirectoryPath('well-known') + '/' }, admin: { diff --git a/packages/tests/src/api/check-params/config.ts b/packages/tests/src/api/check-params/config.ts index ef2bbbef9..7e33e565f 100644 --- a/packages/tests/src/api/check-params/config.ts +++ b/packages/tests/src/api/check-params/config.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { omit } from '@peertube/peertube-core-utils' -import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models' +import { ActorImageType, CustomConfig, HttpStatusCode, LogoType } from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { cleanupTests, @@ -215,10 +215,14 @@ describe('Test config API validators', function () { }) }) - describe('Updating instance image', function () { + describe('Updating instance image/logo', function () { const toTest = [ { path: '/api/v1/config/instance-banner/pick', attachName: 'bannerfile' }, - { path: '/api/v1/config/instance-avatar/pick', attachName: 'avatarfile' } + { path: '/api/v1/config/instance-avatar/pick', attachName: 'avatarfile' }, + { path: '/api/v1/config/instance-logo/favicon/pick', attachName: 'logofile' }, + { path: '/api/v1/config/instance-logo/header-square/pick', attachName: 'logofile' }, + { path: '/api/v1/config/instance-logo/header-wide/pick', attachName: 'logofile' }, + { path: '/api/v1/config/instance-logo/opengraph/pick', attachName: 'logofile' } ] it('Should fail with an incorrect input file', async function () { @@ -311,6 +315,28 @@ describe('Test config API validators', function () { }) }) + describe('Deleting instance logos', function () { + const types: LogoType[] = [ 'favicon', 'header-square', 'header-wide', 'opengraph' ] + + it('Should fail without token', async function () { + for (const type of types) { + await server.config.deleteInstanceLogo({ type, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + } + }) + + it('Should fail without the appropriate rights', async function () { + for (const type of types) { + await server.config.deleteInstanceLogo({ type, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + }) + + it('Should succeed with the correct params', async function () { + for (const type of types) { + await server.config.deleteInstanceLogo({ type }) + } + }) + }) + after(async function () { await cleanupTests([ server ]) }) diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts index 62f384ee9..0c6d870e6 100644 --- a/packages/tests/src/api/server/config.ts +++ b/packages/tests/src/api/server/config.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { ActorImageType, CustomConfig, HttpStatusCode, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models' +import { ActorImageType, CustomConfig, HttpStatusCode, LogoType, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models' import { PeerTubeServer, cleanupTests, @@ -47,6 +47,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { expect(data.services.twitter.username).to.equal('@Chocobozzz') + expect(data.client.header.hideInstanceName).to.be.false expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.false expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.false @@ -222,6 +223,9 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { } }, client: { + header: { + hideInstanceName: true + }, videos: { miniature: { preferAuthorDisplayName: true @@ -691,7 +695,7 @@ describe('Test config', function () { expect(banners).to.have.lengthOf(2) for (const banner of banners) { - await testImage(server.url, `banner-resized-${banner.width}`, banner.path) + await testImage({ url: banner.fileUrl, name: `banner-resized-${banner.width}.jpg` }) await testFileExistsOnFSOrNot(server, 'avatars', basename(banner.path), true) bannerPaths.push(banner.path) @@ -763,6 +767,242 @@ describe('Test config', function () { expect(object.icon).to.not.exist }) }) + + describe('Logos', function () { + describe('Favicon', function () { + const logoPaths: string[] = [] + + it('Should update instance favicon', async function () { + for (const extension of [ '.png', '.gif' ]) { + const fixture = 'avatar' + extension + + await server.config.updateInstanceLogo({ type: 'favicon', fixture }) + + const htmlConfig = await server.config.getConfig() + + const favicons = htmlConfig.instance.logo.filter(l => l.type === 'favicon') + expect(favicons).to.have.lengthOf(1) + expect(favicons[0].width).to.equal(32) + expect(favicons[0].height).to.equal(32) + expect(favicons[0].isFallback).to.be.false + expect(favicons[0].type).to.equal('favicon') + + logoPaths.push(favicons[0].fileUrl) + + await makeRawRequest({ url: favicons[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await testFileExistsOnFSOrNot(server, 'uploads/images', basename(favicons[0].fileUrl), true) + } + }) + + it('Should remove instance favicon', async function () { + await server.config.deleteInstanceLogo({ type: 'favicon' }) + + const htmlConfig = await server.config.getConfig() + + const favicons = htmlConfig.instance.logo.filter(l => l.type === 'favicon') + expect(favicons).to.have.lengthOf(1) + expect(favicons[0].width).to.equal(32) + expect(favicons[0].height).to.equal(32) + expect(favicons[0].isFallback).to.be.true + expect(favicons[0].type).to.equal('favicon') + + await makeRawRequest({ url: favicons[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + + for (const logoPath of logoPaths) { + await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logoPath), false) + } + }) + }) + + describe('Header square icons', function () { + const logoPaths: string[] = [] + + it('Should update instance header square icon', async function () { + for (const extension of [ '.png', '.gif' ]) { + const fixture = 'avatar' + extension + + await server.config.updateInstanceLogo({ type: 'header-square', fixture }) + + const htmlConfig = await server.config.getConfig() + + const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-square') + expect(logos).to.have.lengthOf(1) + expect(logos[0].width).to.equal(48) + expect(logos[0].height).to.equal(48) + expect(logos[0].isFallback).to.be.false + expect(logos[0].type).to.equal('header-square') + + logoPaths.push(logos[0].fileUrl) + + await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logos[0].fileUrl), true) + } + }) + + it('Should remove instance header square icon', async function () { + await server.config.deleteInstanceLogo({ type: 'header-square' }) + + const htmlConfig = await server.config.getConfig() + + const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-square') + expect(logos).to.have.lengthOf(1) + expect(logos[0].width).to.equal(34) + expect(logos[0].height).to.equal(34) + expect(logos[0].isFallback).to.be.true + expect(logos[0].type).to.equal('header-square') + + await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + + for (const logoPath of logoPaths) { + await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logoPath), false) + } + }) + }) + + describe('Header wide icons', function () { + const logoPaths: string[] = [] + + it('Should update instance header wide icon', async function () { + const fixture = 'banner.jpg' + + await server.config.updateInstanceLogo({ type: 'header-wide', fixture }) + + const htmlConfig = await server.config.getConfig() + + const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-wide') + expect(logos).to.have.lengthOf(1) + expect(logos[0].width).to.equal(258) + expect(logos[0].height).to.equal(48) + expect(logos[0].isFallback).to.be.false + expect(logos[0].type).to.equal('header-wide') + + logoPaths.push(logos[0].fileUrl) + + await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logos[0].fileUrl), true) + }) + + it('Should remove instance header wide icon', async function () { + await server.config.deleteInstanceLogo({ type: 'header-wide' }) + + const htmlConfig = await server.config.getConfig() + + const logos = htmlConfig.instance.logo.filter(l => l.type === 'header-wide') + expect(logos).to.have.lengthOf(1) + expect(logos[0].width).to.equal(34) + expect(logos[0].height).to.equal(34) + expect(logos[0].isFallback).to.be.true + expect(logos[0].type).to.equal('header-wide') + + await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + + for (const logoPath of logoPaths) { + await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logoPath), false) + } + }) + }) + + describe('Opengraph icons', function () { + it('Should update instance opengraph icon', async function () { + const fixture = 'banner.jpg' + + await server.config.updateInstanceLogo({ type: 'opengraph', fixture }) + + const htmlConfig = await server.config.getConfig() + + const logos = htmlConfig.instance.logo.filter(l => l.type === 'opengraph') + expect(logos).to.have.lengthOf(1) + expect(logos[0].width).to.equal(1200) + expect(logos[0].height).to.equal(650) + expect(logos[0].isFallback).to.be.false + expect(logos[0].type).to.equal('opengraph') + + await makeRawRequest({ url: logos[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await testFileExistsOnFSOrNot(server, 'uploads/images', basename(logos[0].fileUrl), true) + }) + + it('Should remove instance opengraph icon', async function () { + await server.config.deleteInstanceLogo({ type: 'opengraph' }) + + const htmlConfig = await server.config.getConfig() + + const logos = htmlConfig.instance.logo.filter(l => l.type === 'opengraph') + expect(logos).to.have.lengthOf(0) + }) + }) + + describe('Default logo', function () { + before(async function () { + await server.config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: 'avatar.png' }) + }) + + it('Should default to the avatar logo for the favicon, header icons and opengraph', async function () { + const htmlConfig = await server.config.getConfig() + + const types: LogoType[] = [ 'favicon', 'header-square', 'header-wide', 'opengraph' ] + + for (const type of types) { + const logos = htmlConfig.instance.logo.filter(l => l.type === type) + + expect(logos).to.have.lengthOf(4) + expect(logos[0].width).to.equal(48) + expect(logos[0].height).to.equal(48) + expect(logos[0].isFallback).to.be.true + expect(logos[0].type).to.equal(type) + + await testImage({ url: logos[0].fileUrl, name: `avatar-resized-48x48.png` }) + } + }) + + after(async function () { + await server.config.deleteInstanceImage({ type: ActorImageType.AVATAR }) + }) + }) + }) + }) + + describe('Manifest', function () { + before(async function () { + await server.config.updateExistingConfig({ + newConfig: { + instance: { + name: 'PeerTube manifest', + shortDescription: 'description manifest' + } + } + }) + }) + + it('Should generate the manifest file without avatar', async function () { + const { body } = await makeGetRequest({ + url: server.url, + path: '/manifest.webmanifest', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(body.name).to.equal('PeerTube manifest') + expect(body.short_name).to.equal(body.name) + expect(body.description).to.equal('description manifest') + + const icon = body.icons.find(f => f.sizes === '36x36') + expect(icon).to.exist + expect(icon.src).to.equal('/client/assets/images/icons/icon-36x36.png') + }) + + it('Should generate the manifest with avatar', async function () { + await server.config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: 'avatar.png' }) + + const { body } = await makeGetRequest({ + url: server.url, + path: '/manifest.webmanifest', + expectedStatus: HttpStatusCode.OK_200 + }) + + const icon = body.icons.find(f => f.sizes === '48x48') + expect(icon).to.exist + + await testImage({ url: server.url + icon.src, name: `avatar-resized-48x48.png` }) + }) }) after(async function () { diff --git a/packages/tests/src/api/users/user-import.ts b/packages/tests/src/api/users/user-import.ts index d20e23f6f..56f1b25a5 100644 --- a/packages/tests/src/api/users/user-import.ts +++ b/packages/tests/src/api/users/user-import.ts @@ -198,11 +198,11 @@ function runTest (withObjectStorage: boolean) { expect(importedSecond.support).to.equal('noah support') for (const banner of importedSecond.banners) { - await testImage(remoteServer.url, `banner-user-import-resized-${banner.width}`, banner.path) + await testImage({ url: banner.fileUrl, name: `banner-user-import-resized-${banner.width}.jpg` }) } for (const avatar of importedSecond.avatars) { - await testImage(remoteServer.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + await testImage({ url: remoteServer.url + avatar.path, name: `avatar-resized-${avatar.width}x${avatar.width}.png` }) } { diff --git a/packages/tests/src/api/users/users-multiple-servers.ts b/packages/tests/src/api/users/users-multiple-servers.ts index 61e3aa001..ccf0d6c55 100644 --- a/packages/tests/src/api/users/users-multiple-servers.ts +++ b/packages/tests/src/api/users/users-multiple-servers.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { MyUser } from '@peertube/peertube-models' import { cleanupTests, @@ -14,7 +13,8 @@ import { import { checkActorFilesWereRemoved } from '@tests/shared/actors.js' import { testImage } from '@tests/shared/checks.js' import { checkTmpIsEmpty } from '@tests/shared/directories.js' -import { saveVideoInServers, checkVideoFilesWereRemoved } from '@tests/shared/videos.js' +import { checkVideoFilesWereRemoved, saveVideoInServers } from '@tests/shared/videos.js' +import { expect } from 'chai' describe('Test users with multiple servers', function () { let servers: PeerTubeServer[] = [] @@ -94,7 +94,7 @@ describe('Test users with multiple servers', function () { userAvatarFilenames = user.account.avatars.map(({ path }) => path) for (const avatar of user.account.avatars) { - await testImage(servers[0].url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + await testImage({ url: servers[0].url + avatar.path, name: `avatar2-resized-${avatar.width}x${avatar.width}.png` }) } await waitJobs(servers) @@ -126,7 +126,7 @@ describe('Test users with multiple servers', function () { } for (const avatar of account.avatars) { - await testImage(server.url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + await testImage({ url: server.url + avatar.path, name: `avatar2-resized-${avatar.width}x${avatar.width}.png` }) } } }) diff --git a/packages/tests/src/api/videos/video-channels.ts b/packages/tests/src/api/videos/video-channels.ts index 03aadabbb..fdda1b66f 100644 --- a/packages/tests/src/api/videos/video-channels.ts +++ b/packages/tests/src/api/videos/video-channels.ts @@ -35,8 +35,8 @@ describe('Test video channels', function () { let accountName: string let secondUserChannelName: string - const avatarPaths: { [ port: number ]: string } = {} - const bannerPaths: { [ port: number ]: string } = {} + const avatarPaths: { [port: number]: string } = {} + const bannerPaths: { [port: number]: string } = {} before(async function () { this.timeout(60000) @@ -293,7 +293,7 @@ describe('Test video channels', function () { for (const avatar of videoChannel.avatars) { avatarPaths[server.port] = avatar.path - await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatarPaths[server.port], '.png') + await testImage({ url: server.url + avatarPaths[server.port], name: `avatar-resized-${avatar.width}x${avatar.width}.png` }) await testFileExistsOnFSOrNot(server, 'avatars', basename(avatarPaths[server.port]), true) const row = await sqlCommands[i].getActorImage(basename(avatarPaths[server.port])) @@ -326,7 +326,7 @@ describe('Test video channels', function () { for (const banner of videoChannel.banners) { bannerPaths[server.port] = banner.path - await testImage(server.url, `banner-resized-${banner.width}`, bannerPaths[server.port]) + await testImage({ url: server.url + bannerPaths[server.port], name: `banner-resized-${banner.width}.jpg` }) await testFileExistsOnFSOrNot(server, 'avatars', basename(bannerPaths[server.port]), true) const row = await sqlCommands[i].getActorImage(basename(bannerPaths[server.port])) diff --git a/packages/tests/src/api/videos/video-comments.ts b/packages/tests/src/api/videos/video-comments.ts index a7b2167fe..c3a1e0b2f 100644 --- a/packages/tests/src/api/videos/video-comments.ts +++ b/packages/tests/src/api/videos/video-comments.ts @@ -92,7 +92,7 @@ describe('Test video comments', function () { expect(comment.account.host).to.equal(server.host) for (const avatar of comment.account.avatars) { - await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + await testImage({ url: server.url + avatar.path, name: `avatar-resized-${avatar.width}x${avatar.width}.png` }) } expect(comment.totalReplies).to.equal(0) diff --git a/packages/tests/src/client/head-tags.ts b/packages/tests/src/client/head-tags.ts index 8176f8dfc..f091460d9 100644 --- a/packages/tests/src/client/head-tags.ts +++ b/packages/tests/src/client/head-tags.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { config, expect } from 'chai' +import { findAppropriateImage } from '@peertube/peertube-core-utils' import { Account, HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models' import { cleanupTests, makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands' import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js' +import { config, expect } from 'chai' config.truncateThreshold = 0 @@ -47,6 +48,32 @@ describe('Test HTML tags', function () { } = await prepareClientTests()) }) + describe('Icons', function () { + async function indexPageTest (path: string) { + const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) + const text = res.text + + const config = await servers[0].config.getConfig() + + { + const favicon = config.instance.logo.find(l => l.type === 'favicon') + expect(text).to.contain(``) + } + + { + const appleTouchIcon = findAppropriateImage(config.instance.avatars, 192) + expect(text).to.contain(``) + } + } + + it('Should have valid favicon/ Graph tags on the common page', async function () { + await indexPageTest('/about/peertube') + await indexPageTest('/videos') + await indexPageTest('/homepage') + await indexPageTest('/') + }) + }) + describe('Open Graph', function () { async function indexPageTest (path: string) { const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) @@ -160,6 +187,15 @@ describe('Test HTML tags', function () { await servers[0].config.updateCustomConfig({ newCustomConfig: config }) }) + async function indexPageTest (path: string) { + const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) + const text = res.text + + expect(text).to.contain('') + expect(text).to.contain('') + expect(text).to.contain(` { - await deleteLocalActorImageFile((await getServerActorWithUpdatedImages(imageType)).Account, imageType) + const serverActor = await getServerActor() + + await deleteLocalActorImageFile(serverActor.Account, imageType) + + await updateServerActorImages(imageType) ClientHtml.invalidateCache() ModelCache.Instance.clearCache('server-account') @@ -212,7 +246,7 @@ function deleteInstanceImageFactory (imageType: ActorImageType_Type) { } } -async function getServerActorWithUpdatedImages (imageType: ActorImageType_Type) { +async function updateServerActorImages (imageType: ActorImageType_Type) { const serverActor = await getServerActor() const updatedImages = await ActorImageModel.listByActor(serverActor, imageType) // Reload images from DB @@ -224,6 +258,35 @@ async function getServerActorWithUpdatedImages (imageType: ActorImageType_Type) // --------------------------------------------------------------------------- +async function updateInstanceLogo (req: express.Request, res: express.Response) { + const imagePhysicalFile = req.files['logofile'][0] + + await replaceUploadImage({ + actor: await getServerActor(), + imagePhysicalFile, + type: logoTypeToUploadImageEnum(req.params.logoType as LogoType) + }) + + ClientHtml.invalidateCache() + ModelCache.Instance.clearCache('server-account') + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function deleteInstanceLogo (req: express.Request, res: express.Response) { + await deleteUploadImages({ + actor: await getServerActor(), + type: logoTypeToUploadImageEnum(req.params.logoType as LogoType) + }) + + ClientHtml.invalidateCache() + ModelCache.Instance.clearCache('server-account') + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +// --------------------------------------------------------------------------- + export { configRouter } @@ -293,6 +356,9 @@ function customConfig (): CustomConfig { } }, client: { + header: { + hideInstanceName: CONFIG.CLIENT.HEADER.HIDE_INSTANCE_NAME + }, videos: { miniature: { preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME diff --git a/server/core/controllers/client.ts b/server/core/controllers/client.ts index e23716bd9..5866c38de 100644 --- a/server/core/controllers/client.ts +++ b/server/core/controllers/client.ts @@ -1,13 +1,13 @@ -import express from 'express' -import { constants, promises as fs } from 'fs' -import { readFile } from 'fs/promises' -import { join } from 'path' import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '@peertube/peertube-core-utils' import { HttpStatusCode } from '@peertube/peertube-models' +import { currentDir, root } from '@peertube/peertube-node-utils' import { logger } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' import { Hooks } from '@server/lib/plugins/hooks.js' -import { currentDir, root } from '@peertube/peertube-node-utils' +import { getServerActor } from '@server/models/application/application.js' +import express from 'express' +import { constants, promises as fs } from 'fs' +import { join } from 'path' import { STATIC_MAX_AGE } from '../initializers/constants.js' import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/html/client-html.js' import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares/index.js' @@ -20,7 +20,6 @@ const clientsRateLimiter = buildRateLimiter({ }) const distPath = join(root(), 'client', 'dist') -const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html') // Special route that add OpenGraph and oEmbed tags // Do not use a template engine for a so little thing @@ -59,6 +58,7 @@ clientsRouter.use('/video-playlists/embed/:id', ...embedMiddlewares, asyncMiddle // --------------------------------------------------------------------------- +const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html') const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath) clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController) @@ -72,15 +72,6 @@ clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(g // Static client overrides // Must be consistent with static client overrides redirections in /support/nginx/peertube const staticClientOverrides = [ - 'assets/images/logo.svg', - 'assets/images/favicon.png', - 'assets/images/icons/icon-36x36.png', - 'assets/images/icons/icon-48x48.png', - 'assets/images/icons/icon-72x72.png', - 'assets/images/icons/icon-96x96.png', - 'assets/images/icons/icon-144x144.png', - 'assets/images/icons/icon-192x192.png', - 'assets/images/icons/icon-512x512.png', 'assets/images/default-playlist.jpg', 'assets/images/default-avatar-account.png', 'assets/images/default-avatar-account-48x48.png', @@ -206,15 +197,34 @@ async function generateActorHtmlPage (req: express.Request, res: express.Respons } async function generateManifest (req: express.Request, res: express.Response) { - const manifestPhysicalPath = join(root(), 'client', 'dist', 'manifest.webmanifest') - const manifestJson = await readFile(manifestPhysicalPath, 'utf8') - const manifest = JSON.parse(manifestJson) + const serverActor = await getServerActor() - manifest.name = CONFIG.INSTANCE.NAME - manifest.short_name = CONFIG.INSTANCE.NAME - manifest.description = CONFIG.INSTANCE.SHORT_DESCRIPTION + const defaultIcons = [ 36, 48, 72, 96, 144, 192, 512 ].map(size => { + return { + src: `/client/assets/images/icons/icon-${size}x${size}.png`, + sizes: `36x36`, + type: 'image/png' + } + }) - res.json(manifest) + const icons = Array.isArray(serverActor.Avatars) && serverActor.Avatars.length > 0 + ? serverActor.Avatars.map(avatar => ({ + src: avatar.getStaticPath(), + sizes: `${avatar.width}x${avatar.height}`, + type: avatar.getMimeType() + })) + : defaultIcons + + return res.json({ + name: CONFIG.INSTANCE.NAME, + short_name: CONFIG.INSTANCE.NAME, + start_url: '/', + background_color: '#fff', + theme_color: '#fff', + description: CONFIG.INSTANCE.SHORT_DESCRIPTION, + display: 'standalone', + icons + }) } function serveClientOverride (path: string) { diff --git a/server/core/controllers/feeds/comment-feeds.ts b/server/core/controllers/feeds/comment-feeds.ts index 105ae27ac..fafe98c99 100644 --- a/server/core/controllers/feeds/comment-feeds.ts +++ b/server/core/controllers/feeds/comment-feeds.ts @@ -23,7 +23,8 @@ const { middleware: cacheRouteMiddleware } = cacheRouteFactory({ // --------------------------------------------------------------------------- -commentFeedsRouter.get('/video-comments.:format', +commentFeedsRouter.get( + '/video-comments.:format', feedsFormatValidator, setFeedFormatContentType, cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS), @@ -56,7 +57,7 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel }) - const feed = initFeed({ + const feed = await initFeed({ name, description, imageUrl, diff --git a/server/core/controllers/feeds/shared/common-feed-utils.ts b/server/core/controllers/feeds/shared/common-feed-utils.ts index 9249d1e0d..8866adb8a 100644 --- a/server/core/controllers/feeds/shared/common-feed-utils.ts +++ b/server/core/controllers/feeds/shared/common-feed-utils.ts @@ -5,11 +5,13 @@ import { ActorImageType } from '@peertube/peertube-models' import { mdToPlainText } from '@server/helpers/markdown.js' import { CONFIG } from '@server/initializers/config.js' import { WEBSERVER } from '@server/initializers/constants.js' +import { ServerConfigManager } from '@server/lib/server-config-manager.js' +import { getServerActor } from '@server/models/application/application.js' import { UserModel } from '@server/models/user/user.js' import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models/index.js' import express from 'express' -export function initFeed (parameters: { +export async function initFeed (parameters: { name: string description: string imageUrl: string @@ -46,10 +48,10 @@ export function initFeed (parameters: { image: imageUrl, - favicon: webserverUrl + '/client/assets/images/favicon.png', + favicon: ServerConfigManager.Instance.getFavicon(await getServerActor()).fileUrl, copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` + - ` and potential licenses granted by each content's rightholder.`, + ` and potential licenses granted by each content's rightholder.`, generator: `PeerTube - ${webserverUrl}`, diff --git a/server/core/controllers/feeds/video-feeds.ts b/server/core/controllers/feeds/video-feeds.ts index 2c6f485cf..ed215a68e 100644 --- a/server/core/controllers/feeds/video-feeds.ts +++ b/server/core/controllers/feeds/video-feeds.ts @@ -73,7 +73,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { const { name, description, imageUrl, ownerImageUrl, link, ownerLink } = await buildFeedMetadata({ videoChannel, account }) - const feed = initFeed({ + const feed = await initFeed({ name, description, link, @@ -114,7 +114,7 @@ async function generateVideoFeedForSubscriptions (req: express.Request, res: exp const account = res.locals.account const { name, description, imageUrl, link } = await buildFeedMetadata({ account }) - const feed = initFeed({ + const feed = await initFeed({ name, description, link, diff --git a/server/core/controllers/feeds/video-podcast-feeds.ts b/server/core/controllers/feeds/video-podcast-feeds.ts index 083f3c066..8f613605b 100644 --- a/server/core/controllers/feeds/video-podcast-feeds.ts +++ b/server/core/controllers/feeds/video-podcast-feeds.ts @@ -95,7 +95,7 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp 'filter:feed.podcast.rss.create-custom-xmlns.result' ) - const feed = initFeed({ + const feed = await initFeed({ name, description, link, diff --git a/server/core/controllers/static.ts b/server/core/controllers/static.ts index 6741e748d..4c9b9f6bd 100644 --- a/server/core/controllers/static.ts +++ b/server/core/controllers/static.ts @@ -78,6 +78,14 @@ staticRouter.use( ) // --------------------------------------------------------------------------- +// Uploads +// --------------------------------------------------------------------------- + +staticRouter.use( + STATIC_PATHS.UPLOAD_IMAGES, + express.static(DIRECTORIES.UPLOAD_IMAGES, { fallthrough: false }), + handleStaticError +) export { staticRouter diff --git a/server/core/helpers/custom-validators/actor-images.ts b/server/core/helpers/custom-validators/actor-images.ts index 7f500407d..fed609a0e 100644 --- a/server/core/helpers/custom-validators/actor-images.ts +++ b/server/core/helpers/custom-validators/actor-images.ts @@ -7,7 +7,7 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME .join('|') const imageMimeTypesRegex = `image/(${imageMimeTypes})` -function isActorImageFile (files: UploadFilesForCheck, fieldname: string) { +export function isActorImageFile (files: UploadFilesForCheck, fieldname: string) { return isFileValid({ files, mimeTypeRegex: imageMimeTypesRegex, @@ -15,9 +15,3 @@ function isActorImageFile (files: UploadFilesForCheck, fieldname: string) { maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max }) } - -// --------------------------------------------------------------------------- - -export { - isActorImageFile -} diff --git a/server/core/helpers/custom-validators/config.ts b/server/core/helpers/custom-validators/config.ts new file mode 100644 index 000000000..5ae1ee705 --- /dev/null +++ b/server/core/helpers/custom-validators/config.ts @@ -0,0 +1,7 @@ +import { LogoType } from '@peertube/peertube-models' + +const logoTypes = new Set([ 'favicon', 'header-square', 'header-wide', 'opengraph' ]) + +export function isConfigLogoTypeValid (value: LogoType) { + return logoTypes.has(value) +} diff --git a/server/core/helpers/image-utils.ts b/server/core/helpers/image-utils.ts index 737ccbc9a..d6c13f4d9 100644 --- a/server/core/helpers/image-utils.ts +++ b/server/core/helpers/image-utils.ts @@ -52,6 +52,21 @@ export async function getImageSize (path: string) { } } +// Build new size if height or width is missing, to keep the aspect ratio +export async function buildImageSize (imagePath: string, sizeArg: { width?: number, height?: number }) { + if (sizeArg.width && sizeArg.height) { + return sizeArg as { width: number, height: number } + } + + const size = await getImageSize(imagePath) + const ratio = size.width / size.height + + return { + width: sizeArg.width ?? Math.round(sizeArg.height * ratio), + height: sizeArg.height ?? Math.round(sizeArg.width / ratio) + } +} + // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts index 3c60ad7ad..5f44468e3 100644 --- a/server/core/initializers/checker-before-init.ts +++ b/server/core/initializers/checker-before-init.ts @@ -42,6 +42,7 @@ export function checkMissedConfig () { 'storage.streaming_playlists', 'storage.plugins', 'storage.well_known', + 'storage.uploads', 'log.level', 'log.rotation.enabled', 'log.rotation.max_file_size', @@ -124,6 +125,7 @@ export function checkMissedConfig () { 'trending.videos.interval_days', 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth', + 'client.header.hide_instance_name', 'defaults.publish.download_enabled', 'defaults.publish.comments_policy', 'defaults.publish.privacy', diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts index 06a1b0e5a..003214d7c 100644 --- a/server/core/initializers/config.ts +++ b/server/core/initializers/config.ts @@ -75,6 +75,11 @@ const CONFIG = { }, CLIENT: { + HEADER: { + get HIDE_INSTANCE_NAME () { + return config.get('client.header.hide_instance_name') + } + }, VIDEOS: { MINIATURE: { get PREFER_AUTHOR_DISPLAY_NAME () { @@ -180,7 +185,8 @@ const CONFIG = { CACHE_DIR: buildPath(config.get('storage.cache')), PLUGINS_DIR: buildPath(config.get('storage.plugins')), CLIENT_OVERRIDES_DIR: buildPath(config.get('storage.client_overrides')), - WELL_KNOWN_DIR: buildPath(config.get('storage.well_known')) + WELL_KNOWN_DIR: buildPath(config.get('storage.well_known')), + UPLOADS_DIR: buildPath(config.get('storage.uploads')) }, STATIC_FILES: { PRIVATE_FILES_REQUIRE_AUTH: config.get('static_files.private_files_require_auth') diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 9e9dfbf90..42dd73ed3 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -10,6 +10,8 @@ import { NSFWPolicyType, RunnerJobState, RunnerJobStateType, + UploadImageType, + UploadImageType_Type, UserExportState, UserExportStateType, UserImportState, @@ -46,7 +48,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js' // --------------------------------------------------------------------------- -export const LAST_MIGRATION_VERSION = 895 +export const LAST_MIGRATION_VERSION = 890 // --------------------------------------------------------------------------- @@ -865,7 +867,9 @@ export const STATIC_PATHS = { STREAMING_PLAYLISTS: { HLS: '/static/streaming-playlists/hls', PRIVATE_HLS: '/static/streaming-playlists/hls/private/' - } + }, + + UPLOAD_IMAGES: '/static/uploads/images/' } export const DOWNLOAD_PATHS = { TORRENTS: '/download/torrents/', @@ -942,6 +946,32 @@ export const ACTOR_IMAGES_SIZE: { [key in ActorImageType_Type]: { width: number, } ] } +export const UPLOAD_IMAGES_SIZE: { [key in UploadImageType_Type]: { width: number, height: number }[] } = { + [UploadImageType.INSTANCE_FAVICON]: [ + { + width: 32, + height: 32 + } + ], + [UploadImageType.INSTANCE_HEADER_SQUARE]: [ + { + width: 48, + height: 48 + } + ], + [UploadImageType.INSTANCE_HEADER_WIDE]: [ + { + width: null, // Auto + height: 48 + } + ], + [UploadImageType.INSTANCE_OPENGRAPH]: [ + { + width: 1200, + height: 650 + } + ] +} export const STORYBOARD = { SPRITE_MAX_SIZE: 192, @@ -1014,7 +1044,9 @@ export const DIRECTORIES = { HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls'), - LOCAL_PIP_DIRECTORY: join(CONFIG.STORAGE.BIN_DIR, 'pip') + LOCAL_PIP_DIRECTORY: join(CONFIG.STORAGE.BIN_DIR, 'pip'), + + UPLOAD_IMAGES: join(CONFIG.STORAGE.UPLOADS_DIR, 'images') } export const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS @@ -1236,9 +1268,7 @@ export async function loadLanguages () { // --------------------------------------------------------------------------- export const FILES_CONTENT_HASH = { - MANIFEST: generateContentHash(), - FAVICON: generateContentHash(), - LOGO: generateContentHash() + MANIFEST: generateContentHash() } // --------------------------------------------------------------------------- diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts index bf1eea67d..f5bda4ddd 100644 --- a/server/core/initializers/database.ts +++ b/server/core/initializers/database.ts @@ -1,5 +1,6 @@ import { isTestOrDevInstance } from '@peertube/peertube-node-utils' import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js' +import { UploadImageModel } from '@server/models/application/upload-image.js' import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js' import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js' import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js' @@ -116,7 +117,6 @@ export function checkDatabaseConnectionOrDie () { sequelizeTypescript.authenticate() .then(() => logger.debug('Connection to PostgreSQL has been established successfully.')) .catch(err => { - logger.error('Unable to connect to PostgreSQL database.', { err }) process.exit(-1) }) @@ -186,7 +186,8 @@ export async function initDatabaseModels (silent: boolean) { CommentAutomaticTagModel, AutomaticTagModel, WatchedWordsListModel, - AccountAutomaticTagPolicyModel + AccountAutomaticTagPolicyModel, + UploadImageModel ]) // Check extensions exist in the database @@ -223,7 +224,6 @@ async function checkPostgresExtension (extension: string) { // Try to create the extension ourselves try { await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true }) - } catch { const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` + `You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.` diff --git a/server/core/initializers/migrations/0900-uploads.ts b/server/core/initializers/migrations/0900-uploads.ts new file mode 100644 index 000000000..3d39784eb --- /dev/null +++ b/server/core/initializers/migrations/0900-uploads.ts @@ -0,0 +1,31 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + const query = `CREATE TABLE IF NOT EXISTS "uploadImage"( + "id" serial, + "filename" varchar(255) NOT NULL, + "height" integer DEFAULT NULL, + "width" integer DEFAULT NULL, + "fileUrl" varchar(255), + "type" integer NOT NULL, + "actorId" integer NOT NULL REFERENCES "actor"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + PRIMARY KEY ("id") +);` + + await utils.sequelize.query(query, { transaction: utils.transaction }) +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/core/lib/html/shared/actor-html.ts b/server/core/lib/html/shared/actor-html.ts index 2dbd3bc2a..27a14d33f 100644 --- a/server/core/lib/html/shared/actor-html.ts +++ b/server/core/lib/html/shared/actor-html.ts @@ -4,7 +4,7 @@ import { WEBSERVER } from '@server/initializers/constants.js' import { AccountModel } from '@server/models/account/account.js' import { ActorImageModel } from '@server/models/actor/actor-image.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' -import { MAccountHost, MChannelHost } from '@server/types/models/index.js' +import { MAccountDefault, MChannelDefault } from '@server/types/models/index.js' import express from 'express' import { CONFIG } from '../../../initializers/config.js' import { PageHtml } from './page-html.js' @@ -55,8 +55,8 @@ export class ActorHtml { // --------------------------------------------------------------------------- private static async getAccountOrChannelHTMLPage (options: { - loader: () => Promise - getRSSFeeds: (entity: MAccountHost | MChannelHost) => TagsOptions['rssFeeds'] + loader: () => Promise + getRSSFeeds: (entity: MAccountDefault | MChannelDefault) => TagsOptions['rssFeeds'] req: express.Request res: express.Response }) { diff --git a/server/core/lib/html/shared/page-html.ts b/server/core/lib/html/shared/page-html.ts index 0d76220a2..cc8b95203 100644 --- a/server/core/lib/html/shared/page-html.ts +++ b/server/core/lib/html/shared/page-html.ts @@ -6,10 +6,9 @@ import { is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils' -import { ActorImageType, HTMLServerConfig } from '@peertube/peertube-models' +import { HTMLServerConfig } from '@peertube/peertube-models' import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils' import { CONFIG } from '@server/initializers/config.js' -import { ActorImageModel } from '@server/models/actor/actor-image.js' import { getServerActor } from '@server/models/application/application.js' import express from 'express' import { pathExists } from 'fs-extra/esm' @@ -32,7 +31,8 @@ export class PageHtml { static async getDefaultHTML (req: express.Request, res: express.Response, paramLang?: string) { const html = await this.getIndexHTML(req, res, paramLang) const serverActor = await getServerActor() - const avatar = serverActor.getMaxQualityImage(ActorImageType.AVATAR) + + const openGraphImage = ServerConfigManager.Instance.getDefaultOpenGraph(serverActor) let customHTML = TagsHtml.addTitleTag(html) customHTML = TagsHtml.addDescriptionTag(customHTML) @@ -52,8 +52,8 @@ export class PageHtml { ? TagsHtml.findRelMe(CONFIG.INSTANCE.DESCRIPTION) : undefined, - image: avatar - ? { url: ActorImageModel.getImageUrl(avatar), width: avatar.width, height: avatar.height } + image: openGraphImage + ? { url: openGraphImage.fileUrl, width: openGraphImage.width, height: openGraphImage.height } : undefined, ogType: 'website', @@ -99,8 +99,6 @@ export class PageHtml { let html = buffer.toString() html = this.addManifestContentHash(html) - html = this.addFaviconContentHash(html) - html = this.addLogoContentHash(html) html = this.addCustomCSS(html) html = this.addServerConfig(html, serverConfig) @@ -189,12 +187,4 @@ export class PageHtml { private static addManifestContentHash (htmlStringPage: string) { return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST) } - - private static addFaviconContentHash (htmlStringPage: string) { - return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON) - } - - private static addLogoContentHash (htmlStringPage: string) { - return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO) - } } diff --git a/server/core/lib/html/shared/tags-html.ts b/server/core/lib/html/shared/tags-html.ts index 438e34a3f..c10485317 100644 --- a/server/core/lib/html/shared/tags-html.ts +++ b/server/core/lib/html/shared/tags-html.ts @@ -1,5 +1,7 @@ -import { escapeAttribute, escapeHTML } from '@peertube/peertube-core-utils' +import { escapeAttribute, escapeHTML, findAppropriateImage } from '@peertube/peertube-core-utils' import { mdToPlainText } from '@server/helpers/markdown.js' +import { ServerConfigManager } from '@server/lib/server-config-manager.js' +import { getServerActor } from '@server/models/application/application.js' import truncate from 'lodash-es/truncate.js' import { parse } from 'node-html-parser' import { CONFIG } from '../../../initializers/config.js' @@ -87,26 +89,17 @@ export class TagsHtml { // --------------------------------------------------------------------------- static async addTags (htmlStringPage: string, tagsValues: TagsOptions, context: HookContext) { + const { url, escapedTitle, oembedUrl, forbidIndexation, embedIndexation, relMe, rssFeeds } = tagsValues + const serverActor = await getServerActor() + + let tagsStr = '' + + // Global meta tags const metaTags = { ...this.generateOpenGraphMetaTagsOptions(tagsValues), ...this.generateStandardMetaTagsOptions(tagsValues), ...this.generateTwitterCardMetaTagsOptions(tagsValues) } - const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context) - - const { url, escapedTitle, oembedUrl, forbidIndexation, embedIndexation, relMe, rssFeeds } = tagsValues - - const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = [] - - if (oembedUrl) { - oembedLinkTags.push({ - type: 'application/json+oembed', - href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(oembedUrl), - escapedTitle - }) - } - - let tagsStr = '' for (const tagName of Object.keys(metaTags)) { const tagValue = metaTags[tagName] @@ -116,23 +109,27 @@ export class TagsHtml { } // OEmbed - for (const oembedLinkTag of oembedLinkTags) { - tagsStr += `` + if (oembedUrl) { + const href = WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(oembedUrl) + + tagsStr += `` } // Schema.org + const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context) + if (schemaTags) { tagsStr += `` } + // Rel Me if (Array.isArray(relMe)) { for (const relMeLink of relMe) { tagsStr += `` } } + // SEO if (forbidIndexation === true) { tagsStr += `` } else if (embedIndexation) { @@ -141,10 +138,23 @@ export class TagsHtml { tagsStr += `` } + // RSS for (const rssLink of (rssFeeds || [])) { tagsStr += `` } + // Favicon + const favicon = ServerConfigManager.Instance.getFavicon(serverActor) + tagsStr += `` + + // Apple Touch Icon + const appleTouchIcon = findAppropriateImage(serverActor.Avatars, 192) + const iconHref = appleTouchIcon + ? WEBSERVER.URL + appleTouchIcon.getStaticPath() + : '/client/assets/images/icons/icon-192x192.png' + + tagsStr += `` + return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr) } diff --git a/server/core/lib/server-config-manager.ts b/server/core/lib/server-config-manager.ts index fe7b6c300..1a37d12a4 100644 --- a/server/core/lib/server-config-manager.ts +++ b/server/core/lib/server-config-manager.ts @@ -1,5 +1,8 @@ +import { findAppropriateImage, maxBy } from '@peertube/peertube-core-utils' import { + ActorImageType, HTMLServerConfig, + LogoType, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig, @@ -8,15 +11,19 @@ import { } from '@peertube/peertube-models' import { getServerCommit } from '@server/helpers/version.js' import { CONFIG, isEmailEnabled } from '@server/initializers/config.js' -import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants.js' +import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, WEBSERVER } from '@server/initializers/constants.js' import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/lib/signup.js' import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js' +import { ActorImageModel } from '@server/models/actor/actor-image.js' import { getServerActor } from '@server/models/application/application.js' +import { UploadImageModel } from '@server/models/application/upload-image.js' import { PluginModel } from '@server/models/server/plugin.js' +import { MActorImage, MActorUploadImages, MUploadImage } from '@server/types/models/index.js' import { Hooks } from './plugins/hooks.js' import { PluginManager } from './plugins/plugin-manager.js' import { getThemeOrDefault } from './plugins/theme-utils.js' import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles.js' +import { logoTypeToUploadImageEnum } from './upload-image.js' /** * Used to send the server config to clients (using REST/API or plugins API) @@ -51,6 +58,9 @@ class ServerConfigManager { return { client: { + header: { + hideInstanceName: CONFIG.CLIENT.HEADER.HIDE_INSTANCE_NAME + }, videos: { miniature: { preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME @@ -133,8 +143,16 @@ class ServerConfigManager { javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT, css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS }, + avatars: serverActor.Avatars.map(a => a.toFormattedJSON()), - banners: serverActor.Banners.map(b => b.toFormattedJSON()) + banners: serverActor.Banners.map(b => b.toFormattedJSON()), + + logo: [ + ...this.getFaviconLogos(serverActor), + ...this.getMobileHeaderLogos(serverActor), + ...this.getDesktopHeaderLogos(serverActor), + ...this.getOpenGraphLogos(serverActor) + ] }, search: { remoteUri: { @@ -482,6 +500,117 @@ class ServerConfigManager { return result } + // --------------------------------------------------------------------------- + // Logo + // --------------------------------------------------------------------------- + + getFavicon (serverActor: MActorUploadImages) { + return findAppropriateImage(this.getFaviconLogos(serverActor), 32) + } + + getDefaultOpenGraph (serverActor: MActorUploadImages) { + return maxBy(this.getOpenGraphLogos(serverActor), 'width') + } + + private getFaviconLogos (serverActor: MActorUploadImages) { + return this.getLogoWithFallbacks({ + serverActor, + logoType: 'favicon', + + defaultLogo: { + fileUrl: WEBSERVER.URL + '/client/assets/images/favicon.png', + width: 32, + height: 32 + } + }) + } + + private getMobileHeaderLogos (serverActor: MActorUploadImages) { + return this.getLogoWithFallbacks({ + serverActor, + logoType: 'header-square', + + defaultLogo: { + fileUrl: WEBSERVER.URL + '/client/assets/images/logo.svg', + width: 34, + height: 34 + } + }) + } + + private getDesktopHeaderLogos (serverActor: MActorUploadImages) { + return this.getLogoWithFallbacks({ + serverActor, + logoType: 'header-wide', + + defaultLogo: { + fileUrl: WEBSERVER.URL + '/client/assets/images/logo.svg', + width: 34, + height: 34 + } + }) + } + + private getOpenGraphLogos (serverActor: MActorUploadImages) { + return this.getLogoWithFallbacks({ + serverActor, + logoType: 'opengraph', + + defaultLogo: undefined + }) + } + + private getLogoWithFallbacks (options: { + serverActor: MActorUploadImages + logoType: LogoType + + defaultLogo: { + fileUrl: string + width: number + height: number + } + }) { + const { serverActor, logoType, defaultLogo } = options + + const uploadImageType = logoTypeToUploadImageEnum(logoType) + + const uploaded = serverActor.UploadImages + .filter(i => i.type === uploadImageType) + .map(i => this.formatUploadImageForLogo(i, logoType, false)) + + if (uploaded.length !== 0) return uploaded + + // Avatar fallback? + if (serverActor.hasImage(ActorImageType.AVATAR)) { + return serverActor.Avatars.map(a => this.formatActorImageForLogo(a, logoType, true)) + } + + // Default mobile header logo? + if (!defaultLogo) return [] + + return [ { ...defaultLogo, type: logoType, isFallback: true } ] + } + + private formatUploadImageForLogo (logo: MUploadImage, type: LogoType, isFallback: boolean) { + return { + height: logo.height, + width: logo.width, + type, + fileUrl: UploadImageModel.getImageUrl(logo), + isFallback + } + } + + private formatActorImageForLogo (logo: MActorImage, type: LogoType, isFallback: boolean) { + return { + height: logo.height, + width: logo.width, + type, + fileUrl: ActorImageModel.getImageUrl(logo), + isFallback + } + } + static get Instance () { return this.instance || (this.instance = new this()) } diff --git a/server/core/lib/upload-image.ts b/server/core/lib/upload-image.ts new file mode 100644 index 000000000..b5bf0fad5 --- /dev/null +++ b/server/core/lib/upload-image.ts @@ -0,0 +1,96 @@ +import { LogoType, UploadImageType, UploadImageType_Type } from '@peertube/peertube-models' +import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils' +import { buildImageSize } from '@server/helpers/image-utils.js' +import { UploadImageModel } from '@server/models/application/upload-image.js' +import { remove } from 'fs-extra/esm' +import { retryTransactionWrapper } from '../helpers/database-utils.js' +import { UPLOAD_IMAGES_SIZE } from '../initializers/constants.js' +import { sequelizeTypescript } from '../initializers/database.js' +import { MActorUploadImages } from '../types/models/index.js' +import { processImageFromWorker } from './worker/parent-process.js' + +export async function replaceUploadImage (options: { + actor: MActorUploadImages + imagePhysicalFile: { path: string } + type: UploadImageType_Type +}) { + const { actor, imagePhysicalFile, type } = options + + const processImageSize = async (imageSizeArg: { width: number, height: number }) => { + const imageSize = await buildImageSize(imagePhysicalFile.path, imageSizeArg) + + const extension = getLowercaseExtension(imagePhysicalFile.path) + const imageName = buildUUID() + extension + const destination = UploadImageModel.getPathOf(imageName) + + await processImageFromWorker({ path: imagePhysicalFile.path, destination, newSize: imageSize, keepOriginal: true }) + + return { imageName, imageSize } + } + + const processedImages = await Promise.all(UPLOAD_IMAGES_SIZE[type].map(processImageSize)) + await remove(imagePhysicalFile.path) + + return retryTransactionWrapper(() => + sequelizeTypescript.transaction(async t => { + const imagesToDelete = await UploadImageModel.listByActorAndType(actor, type, t) + + for (const toDelete of imagesToDelete) { + await toDelete.destroy({ transaction: t }) + + actor.UploadImages = actor.UploadImages.filter(image => image.id !== toDelete.id) + } + + for (const toCreate of processedImages) { + const uploadImage = await UploadImageModel.create({ + filename: toCreate.imageName, + height: toCreate.imageSize.height, + width: toCreate.imageSize.width, + fileUrl: null, + type, + actorId: actor.id + }, { transaction: t }) + + actor.UploadImages.push(uploadImage) + } + }) + ) +} + +export async function deleteUploadImages (options: { + actor: MActorUploadImages + type: UploadImageType_Type +}) { + const { actor, type } = options + + return retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(async t => { + const imagesToDelete = await UploadImageModel.listByActorAndType(actor, type, t) + + for (const toDelete of imagesToDelete) { + await toDelete.destroy({ transaction: t }) + } + + actor.UploadImages = [] + }) + }) +} + +export function logoTypeToUploadImageEnum (logoType: LogoType) { + switch (logoType) { + case 'favicon': + return UploadImageType.INSTANCE_FAVICON + + case 'header-wide': + return UploadImageType.INSTANCE_HEADER_WIDE + + case 'header-square': + return UploadImageType.INSTANCE_HEADER_SQUARE + + case 'opengraph': + return UploadImageType.INSTANCE_OPENGRAPH + + default: + return logoType satisfies never + } +} diff --git a/server/core/middlewares/validators/actor-image.ts b/server/core/middlewares/validators/actor-image.ts index 4d742e544..ca0b12986 100644 --- a/server/core/middlewares/validators/actor-image.ts +++ b/server/core/middlewares/validators/actor-image.ts @@ -1,22 +1,4 @@ -import express from 'express' -import { body } from 'express-validator' -import { isActorImageFile } from '@server/helpers/custom-validators/actor-images.js' -import { cleanUpReqFiles } from '../../helpers/express-utils.js' -import { CONSTRAINTS_FIELDS } from '../../initializers/constants.js' -import { areValidationErrors } from './shared/index.js' - -const updateActorImageValidatorFactory = (fieldname: string) => ([ - body(fieldname).custom((value, { req }) => isActorImageFile(req.files, fieldname)).withMessage( - 'This file is not supported or too large. Please, make sure it is of the following type : ' + - CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME.join(', ') - ), - - (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (areValidationErrors(req, res)) return cleanUpReqFiles(req) - - return next() - } -]) +import { updateActorImageValidatorFactory } from './shared/images.js' export const updateAvatarValidator = updateActorImageValidatorFactory('avatarfile') export const updateBannerValidator = updateActorImageValidatorFactory('bannerfile') diff --git a/server/core/middlewares/validators/config.ts b/server/core/middlewares/validators/config.ts index 878500030..f689cfe0a 100644 --- a/server/core/middlewares/validators/config.ts +++ b/server/core/middlewares/validators/config.ts @@ -1,16 +1,17 @@ -import express from 'express' -import { body } from 'express-validator' import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models' +import { isConfigLogoTypeValid } from '@server/helpers/custom-validators/config.js' import { isIntOrNull } from '@server/helpers/custom-validators/misc.js' +import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js' +import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.js' import { CONFIG, isEmailEnabled } from '@server/initializers/config.js' +import express from 'express' +import { body, param } from 'express-validator' import { isThemeNameValid } from '../../helpers/custom-validators/plugins.js' import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js' import { isThemeRegistered } from '../../lib/plugins/theme-utils.js' -import { areValidationErrors } from './shared/index.js' -import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js' -import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.js' +import { areValidationErrors, updateActorImageValidatorFactory } from './shared/index.js' -const customConfigUpdateValidator = [ +export const customConfigUpdateValidator = [ body('instance.name').exists(), body('instance.shortDescription').exists(), body('instance.description').exists(), @@ -161,7 +162,7 @@ const customConfigUpdateValidator = [ } ] -function ensureConfigIsEditable (req: express.Request, res: express.Response, next: express.NextFunction) { +export function ensureConfigIsEditable (req: express.Request, res: express.Response, next: express.NextFunction) { if (!CONFIG.WEBADMIN.CONFIGURATION.EDITION.ALLOWED) { return res.fail({ status: HttpStatusCode.METHOD_NOT_ALLOWED_405, @@ -172,12 +173,22 @@ function ensureConfigIsEditable (req: express.Request, res: express.Response, ne return next() } -// --------------------------------------------------------------------------- +export const updateOrDeleteLogoValidator = [ + param('logoType') + .custom(isConfigLogoTypeValid), -export { - customConfigUpdateValidator, - ensureConfigIsEditable -} + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + return next() + } +] + +export const updateInstanceLogoValidator = updateActorImageValidatorFactory('logofile') + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- function checkInvalidConfigIfEmailDisabled (customConfig: CustomConfig, res: express.Response) { if (isEmailEnabled()) return true diff --git a/server/core/middlewares/validators/shared/images.ts b/server/core/middlewares/validators/shared/images.ts new file mode 100644 index 000000000..f3802cf58 --- /dev/null +++ b/server/core/middlewares/validators/shared/images.ts @@ -0,0 +1,19 @@ +import { isActorImageFile } from '@server/helpers/custom-validators/actor-images.js' +import { cleanUpReqFiles } from '@server/helpers/express-utils.js' +import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' +import express from 'express' +import { body } from 'express-validator' +import { areValidationErrors } from './utils.js' + +export const updateActorImageValidatorFactory = (fieldname: string) => [ + body(fieldname).custom((value, { req }) => isActorImageFile(req.files, fieldname)).withMessage( + 'This file is not supported or too large. Please, make sure it is of the following type : ' + + CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME.join(', ') + ), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + + return next() + } +] diff --git a/server/core/middlewares/validators/shared/index.ts b/server/core/middlewares/validators/shared/index.ts index d60ac49ca..6bb552b32 100644 --- a/server/core/middlewares/validators/shared/index.ts +++ b/server/core/middlewares/validators/shared/index.ts @@ -1,5 +1,6 @@ export * from './abuses.js' export * from './accounts.js' +export * from './images.js' export * from './users.js' export * from './utils.js' export * from './video-blacklists.js' diff --git a/server/core/models/actor/actor-image.ts b/server/core/models/actor/actor-image.ts index 79c5fbc3c..7a1272e67 100644 --- a/server/core/models/actor/actor-image.ts +++ b/server/core/models/actor/actor-image.ts @@ -4,16 +4,7 @@ import { MActorId, MActorImage, MActorImageFormattable, MActorImagePath } from ' import { remove } from 'fs-extra/esm' import { join } from 'path' import { Op } from 'sequelize' -import { - AfterDestroy, - AllowNull, - BelongsTo, - Column, - CreatedAt, - Default, - ForeignKey, Table, - UpdatedAt -} from 'sequelize-typescript' +import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript' import { logger } from '../../helpers/logger.js' import { CONFIG } from '../../initializers/config.js' import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants.js' @@ -34,7 +25,6 @@ import { ActorModel } from './actor.js' ] }) export class ActorImageModel extends SequelizeModel { - @AllowNull(false) @Column filename: string @@ -159,6 +149,7 @@ export class ActorImageModel extends SequelizeModel { toFormattedJSON (this: MActorImageFormattable): ActorImage { return { + height: this.height, width: this.width, path: this.getStaticPath(), fileUrl: ActorImageModel.getImageUrl(this), @@ -168,11 +159,9 @@ export class ActorImageModel extends SequelizeModel { } toActivityPubObject (): ActivityIconObject { - const extension = getLowercaseExtension(this.filename) - return { type: 'Image', - mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension], + mediaType: this.getMimeType(), height: this.height, width: this.width, url: ActorImageModel.getImageUrl(this) @@ -204,4 +193,8 @@ export class ActorImageModel extends SequelizeModel { isOwned () { return !this.fileUrl } + + getMimeType () { + return MIMETYPES.IMAGE.EXT_MIMETYPE[getLowercaseExtension(this.filename)] + } } diff --git a/server/core/models/actor/actor.ts b/server/core/models/actor/actor.ts index 8a9f02b85..65e4c57d9 100644 --- a/server/core/models/actor/actor.ts +++ b/server/core/models/actor/actor.ts @@ -1,4 +1,4 @@ -import { forceNumber, maxBy } from '@peertube/peertube-core-utils' +import { findAppropriateImage, forceNumber, maxBy } from '@peertube/peertube-core-utils' import { ActivityIconObject, ActorImageType, ActorImageType_Type, type ActivityPubActorType } from '@peertube/peertube-models' import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { activityPubContextify } from '@server/helpers/activity-pub-utils.js' @@ -47,6 +47,7 @@ import { } from '../../types/models/index.js' import { AccountModel } from '../account/account.js' import { getServerActor } from '../application/application.js' +import { UploadImageModel } from '../application/upload-image.js' import { ServerModel } from '../server/server.js' import { SequelizeModel, buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared/index.js' import { VideoChannelModel } from '../video/video-channel.js' @@ -250,6 +251,16 @@ export class ActorModel extends SequelizeModel { }) Banners: Awaited[] + @HasMany(() => UploadImageModel, { + as: 'UploadImages', + onDelete: 'cascade', + hooks: true, + foreignKey: { + allowNull: false + } + }) + UploadImages: Awaited[] + @HasMany(() => ActorFollowModel, { foreignKey: { name: 'actorId', @@ -686,6 +697,16 @@ export class ActorModel extends SequelizeModel { return maxBy(images, 'height') } + getAppropriateQualityImage (type: ActorImageType_Type, width: number) { + if (!this.hasImage(type)) return undefined + + const images = type === ActorImageType.AVATAR + ? this.Avatars + : this.Banners + + return findAppropriateImage(images, width) + } + isOutdated () { if (this.isOwned()) return false diff --git a/server/core/models/application/application.ts b/server/core/models/application/application.ts index 8b8c6bc5a..15ee7b727 100644 --- a/server/core/models/application/application.ts +++ b/server/core/models/application/application.ts @@ -4,6 +4,7 @@ import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Table } from ' import { AccountModel } from '../account/account.js' import { ActorImageModel } from '../actor/actor-image.js' import { SequelizeModel } from '../shared/index.js' +import { UploadImageModel } from './upload-image.js' export const getServerActor = memoizee(async function () { const application = await ApplicationModel.load() @@ -16,6 +17,9 @@ export const getServerActor = memoizee(async function () { actor.Avatars = avatars actor.Banners = banners + const uploadImages = await UploadImageModel.listByActor(actor) + actor.UploadImages = uploadImages + return actor }, { promise: true }) @@ -32,7 +36,6 @@ export const getServerActor = memoizee(async function () { timestamps: false }) export class ApplicationModel extends SequelizeModel { - @AllowNull(false) @Default(0) @IsInt diff --git a/server/core/models/application/upload-image.ts b/server/core/models/application/upload-image.ts new file mode 100644 index 000000000..8327bb61e --- /dev/null +++ b/server/core/models/application/upload-image.ts @@ -0,0 +1,127 @@ +import { type UploadImageType_Type } from '@peertube/peertube-models' +import { MActorId, MUploadImage } from '@server/types/models/index.js' +import { remove } from 'fs-extra/esm' +import { join } from 'path' +import { Transaction } from 'sequelize' +import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript' +import { logger } from '../../helpers/logger.js' +import { DIRECTORIES, STATIC_PATHS, WEBSERVER } from '../../initializers/constants.js' +import { ActorModel } from '../actor/actor.js' +import { SequelizeModel } from '../shared/index.js' + +// Image uploads that are not suitable for other tables actor images (avatars/banners) +// Can be used to store instance images like logos, favicons, etc. + +@Table({ + tableName: 'uploadImage', + indexes: [ + { + fields: [ 'filename' ], + unique: true + }, + { + fields: [ 'actorId', 'type', 'width' ], + unique: true + } + ] +}) +export class UploadImageModel extends SequelizeModel { + @AllowNull(false) + @Column + filename: string + + @AllowNull(true) + @Default(null) + @Column + height: number + + @AllowNull(true) + @Default(null) + @Column + width: number + + @AllowNull(true) + @Column + fileUrl: string + + @AllowNull(false) + @Column + type: UploadImageType_Type + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => ActorModel) + @Column + actorId: number + + @BelongsTo(() => ActorModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Actor: Awaited + + @AfterDestroy + static removeFile (instance: UploadImageModel) { + logger.info('Removing upload image file %s.', instance.filename) + + // Don't block the transaction + instance.removeImage() + .catch(err => logger.error('Cannot remove upload image file %s.', instance.filename, { err })) + } + + static listByActor (actor: MActorId) { + const query = { + where: { + actorId: actor.id + } + } + + return UploadImageModel.findAll(query) + } + + static listByActorAndType (actor: MActorId, type: UploadImageType_Type, transaction: Transaction) { + const query = { + where: { + actorId: actor.id, + type + }, + transaction + } + + return UploadImageModel.findAll(query) + } + + static getImageUrl (image: MUploadImage) { + if (!image) return undefined + + return WEBSERVER.URL + image.getStaticPath() + } + + static getPathOf (filename: string) { + return join(DIRECTORIES.UPLOAD_IMAGES, filename) + } + + // --------------------------------------------------------------------------- + + getStaticPath (this: MUploadImage) { + return join(STATIC_PATHS.UPLOAD_IMAGES, this.filename) + } + + getPath () { + return UploadImageModel.getPathOf(this.filename) + } + + removeImage () { + return remove(this.getPath()) + } + + isOwned () { + return !this.fileUrl + } +} diff --git a/server/core/models/user/user.ts b/server/core/models/user/user.ts index 796ab0be7..113de4f20 100644 --- a/server/core/models/user/user.ts +++ b/server/core/models/user/user.ts @@ -385,7 +385,7 @@ export class UserModel extends SequelizeModel { @Default(UserAdminFlag.NONE) @Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags')) @Column - adminFlags?: UserAdminFlagType + adminFlags: UserAdminFlagType @AllowNull(false) @Default(false) diff --git a/server/core/types/models/actor/actor-image.ts b/server/core/types/models/actor/actor-image.ts index 753e0666b..0a040ce87 100644 --- a/server/core/types/models/actor/actor-image.ts +++ b/server/core/types/models/actor/actor-image.ts @@ -11,4 +11,4 @@ export type MActorImagePath = Pick - & Pick + & Pick diff --git a/server/core/types/models/actor/actor.ts b/server/core/types/models/actor/actor.ts index b5fd54532..83e56cc9d 100644 --- a/server/core/types/models/actor/actor.ts +++ b/server/core/types/models/actor/actor.ts @@ -1,6 +1,7 @@ import { FunctionProperties, PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils' import { ActorModel } from '../../../models/actor/actor.js' import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from '../account/index.js' +import { MUploadImage } from '../application/upload-image.js' import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server/index.js' import { MChannel, MChannelAccountActor, MChannelAccountDefault, MChannelId, MChannelIdActor } from '../video/index.js' import { MActorImage, MActorImageFormattable } from './actor-image.js' @@ -10,7 +11,10 @@ type UseOpt = PickWithOpt // ############################################################################ -export type MActor = Omit +export type MActor = Omit< + ActorModel, + 'Account' | 'VideoChannel' | 'ActorFollowing' | 'ActorFollowers' | 'Server' | 'Banners' | 'Avatars' | 'UploadImages' +> // ############################################################################ @@ -61,6 +65,8 @@ export type MActorChannelIdActor = export type MActorAccountChannelId = MActorAccountId & MActorChannelId export type MActorAccountChannelIdActor = MActorAccountIdActor & MActorChannelIdActor +export type MActorUploadImages = MActorImages & Use<'UploadImages', MUploadImage[]> + // ############################################################################ // Include raw account/channel/server diff --git a/server/core/types/models/application/index.ts b/server/core/types/models/application/index.ts index fbbab9760..b3f7ecf91 100644 --- a/server/core/types/models/application/index.ts +++ b/server/core/types/models/application/index.ts @@ -1 +1,2 @@ export * from './application.js' +export * from './upload-image.js' diff --git a/server/core/types/models/application/upload-image.ts b/server/core/types/models/application/upload-image.ts new file mode 100644 index 000000000..ede0d8ea5 --- /dev/null +++ b/server/core/types/models/application/upload-image.ts @@ -0,0 +1,3 @@ +import { UploadImageModel } from '@server/models/application/upload-image.js' + +export type MUploadImage = UploadImageModel diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 655fea0fe..b5fbba22b 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -1076,6 +1076,55 @@ paths: '204': description: successful operation + '/api/v1/config/instance-logo/:logoType/pick': + post: + summary: Update instance logo + security: + - OAuth2: + - admin + tags: + - Config + parameters: + - $ref: '#/components/parameters/logoTypeParam' + responses: + '204': + description: successful operation + '413': + description: image file too large + headers: + X-File-Maximum-Size: + schema: + type: string + format: Nginx size + description: Maximum file size for the banner + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + logofile: + description: The file to upload. + type: string + format: binary + encoding: + logofile: + contentType: image/png, image/jpeg + + '/api/v1/config/instance-logo/:logoType': + delete: + summary: Delete instance logo + security: + - OAuth2: + - admin + tags: + - Config + parameters: + - $ref: '#/components/parameters/logoTypeParam' + responses: + '204': + description: successful operation + /api/v1/custom-pages/homepage/instance: get: summary: Get instance custom homepage @@ -8022,7 +8071,17 @@ components: not valid anymore and you need to initialize a new upload. schema: type: string - + logoTypeParam: + name: logoType + in: path + required: true + schema: + type: string + enum: + - 'favicon' + - 'header-wide' + - 'header-square' + - 'opengraph' securitySchemes: OAuth2: @@ -9156,10 +9215,10 @@ components: type: number description: "**PeerTube >= 6.1** Frames per second of the video file" width: - type: number + type: integer description: "**PeerTube >= 6.1** Video stream width" height: - type: number + type: integer description: "**PeerTube >= 6.1** Video stream height" createdAt: type: string @@ -9170,6 +9229,9 @@ components: type: string width: type: integer + height: + type: integer + description: "**PeerTube >= 7.3** ImportVideosInChannelCreate:mage height" createdAt: type: string format: date-time diff --git a/support/docker/production/config/production.yaml b/support/docker/production/config/production.yaml index 21d28abe1..c686d7c11 100644 --- a/support/docker/production/config/production.yaml +++ b/support/docker/production/config/production.yaml @@ -1,8 +1,9 @@ +# Check config/production.yaml.example in PeerTube repository for more details/available configuration + listen: hostname: '0.0.0.0' port: 9000 -# Correspond to your reverse proxy server_name/listen configuration (i.e., your public PeerTube instance URL) webserver: https: true hostname: undefined @@ -10,23 +11,17 @@ webserver: rates_limit: login: - # 15 attempts in 5 min window: 5 minutes max: 15 ask_send_email: - # 3 attempts in 5 min window: 5 minutes max: 3 -# Proxies to trust to get real client IP -# If you run PeerTube just behind a local proxy (nginx), keep 'loopback' -# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) trust_proxy: - 'loopback' - 'linklocal' - 'uniquelocal' -# Your database name will be database.name OR 'peertube'+database.suffix database: hostname: 'postgres' port: 5432 @@ -35,7 +30,6 @@ database: username: 'postgres' password: 'postgres' -# Redis server for short time storage redis: hostname: 'redis' port: 6379 @@ -43,8 +37,8 @@ redis: # From the project root directory storage: - tmp: '../data/tmp/' # Use to download data (imports etc), store uploaded files before and during processing... - tmp_persistent: '../data/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts + tmp: '../data/tmp/' + tmp_persistent: '../data/tmp-persistent/' bin: '../data/bin/' avatars: '../data/avatars/' web_videos: '../data/web-videos/' @@ -59,17 +53,8 @@ storage: captions: '../data/captions/' cache: '../data/cache/' plugins: '../data/plugins/' + uploads: '../data/uploads/' well_known: '../data/well-known/' - # Overridable client files in client/dist/assets/images: - # - logo.svg - # - favicon.png - # - default-playlist.jpg - # - default-avatar-account.png - # - default-avatar-video-channel.png - # - and icons/*.png (PWA) - # Could contain for example assets/images/favicon.png - # If the file exists, peertube will serve it - # If not, peertube will fallback to the default file client_overrides: '../data/client-overrides/' @@ -79,7 +64,7 @@ object_storage: private: null log: - level: 'info' # 'debug' | 'info' | 'warn' | 'error' + level: 'info' tracker: enabled: true diff --git a/support/nginx/peertube b/support/nginx/peertube index 01bbbf35a..a697ba6b3 100644 --- a/support/nginx/peertube +++ b/support/nginx/peertube @@ -190,7 +190,7 @@ server { # Bypass PeerTube for performance reasons. Optional. # Should be consistent with client-overrides assets list in client.ts server controller - location ~ ^/client/(assets/images/(icons/icon-36x36\.png|icons/icon-48x48\.png|icons/icon-72x72\.png|icons/icon-96x96\.png|icons/icon-144x144\.png|icons/icon-192x192\.png|icons/icon-512x512\.png|logo\.svg|favicon\.png|default-playlist\.jpg|default-avatar-account\.png|default-avatar-account-48x48\.png|default-avatar-video-channel\.png|default-avatar-video-channel-48x48\.png))$ { + location ~ ^/client/(assets/images/(default-playlist\.jpg|default-avatar-account\.png|default-avatar-account-48x48\.png|default-avatar-video-channel\.png|default-avatar-video-channel-48x48\.png))$ { add_header Cache-Control "public, max-age=31536000, immutable"; # Cache 1 year root /var/www/peertube;