diff --git a/backend/src/collections/preference-db/preference-defaults.service.ts b/backend/src/collections/preference-db/preference-defaults.service.ts index d2c872c8..a084b1db 100644 --- a/backend/src/collections/preference-db/preference-defaults.service.ts +++ b/backend/src/collections/preference-db/preference-defaults.service.ts @@ -24,6 +24,8 @@ export class PreferenceDefaultsService { private readonly sysDefaults: { [key in SysPreference]: (() => PrefValueType) | PrefValueType; } = { + [SysPreference.HostOverride]: '', + [SysPreference.JwtSecret]: () => { const envSecret = this.jwtConfigService.getJwtSecret(); if (envSecret) { diff --git a/backend/src/collections/preference-db/sys-preference-db.service.ts b/backend/src/collections/preference-db/sys-preference-db.service.ts index e3e853c6..2d515b0f 100644 --- a/backend/src/collections/preference-db/sys-preference-db.service.ts +++ b/backend/src/collections/preference-db/sys-preference-db.service.ts @@ -3,19 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DecodedSysPref, PrefValueType, - PrefValueTypeStrings, + PrefValueTypeStrings } from 'picsur-shared/dist/dto/preferences.dto'; import { SysPreference, SysPreferenceList, SysPreferenceValidators, - SysPreferenceValueTypes, + SysPreferenceValueTypes } from 'picsur-shared/dist/dto/sys-preferences.enum'; import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; import { Repository } from 'typeorm'; import { ESysPreferenceBackend, - ESysPreferenceSchema, + ESysPreferenceSchema } from '../../database/entities/sys-preference.entity'; import { MutexFallBack } from '../../util/mutex-fallback'; import { PreferenceCommonService } from './preference-common.service'; @@ -155,6 +155,9 @@ export class SysPreferenceDbService { const valueValidated = SysPreferenceValidators[key as SysPreference].safeParse( value, ); + if (!valueValidated.success) { + return Fail(FT.UsrValidation, undefined, valueValidated.error); + } let verifySysPreference = new ESysPreferenceBackend(); verifySysPreference.key = validated.key; diff --git a/backend/src/collections/preference-db/usr-preference-db.service.ts b/backend/src/collections/preference-db/usr-preference-db.service.ts index 0fbb12cd..de814c10 100644 --- a/backend/src/collections/preference-db/usr-preference-db.service.ts +++ b/backend/src/collections/preference-db/usr-preference-db.service.ts @@ -3,19 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DecodedUsrPref, PrefValueType, - PrefValueTypeStrings, + PrefValueTypeStrings } from 'picsur-shared/dist/dto/preferences.dto'; import { UsrPreference, UsrPreferenceList, UsrPreferenceValidators, - UsrPreferenceValueTypes, + UsrPreferenceValueTypes } from 'picsur-shared/dist/dto/usr-preferences.enum'; import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; import { Repository } from 'typeorm'; import { EUsrPreferenceBackend, - EUsrPreferenceSchema, + EUsrPreferenceSchema } from '../../database/entities/usr-preference.entity'; import { MutexFallBack } from '../../util/mutex-fallback'; import { PreferenceCommonService } from './preference-common.service'; @@ -193,15 +193,11 @@ export class UsrPreferenceDbService { ); if (HasFailed(validated)) return validated; - if (!UsrPreferenceValidators[validated.key](validated.value)) - throw Fail( - FT.UsrValidation, - undefined, - 'Preference validator failed for ' + - validated.key + - ' with value ' + - validated.value, - ); + const valueValidated = + UsrPreferenceValidators[key as UsrPreference].safeParse(value); + if (!valueValidated.success) { + return Fail(FT.UsrValidation, undefined, valueValidated.error); + } let verifySysPreference = new EUsrPreferenceBackend(); verifySysPreference.key = validated.key; diff --git a/backend/src/config/late/info.config.service.ts b/backend/src/config/late/info.config.service.ts new file mode 100644 index 00000000..cd6017fb --- /dev/null +++ b/backend/src/config/late/info.config.service.ts @@ -0,0 +1,23 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service'; + +@Injectable() +export class InfoConfigService { + private readonly logger = new Logger(InfoConfigService.name); + + constructor(private readonly prefService: SysPreferenceDbService) {} + + public async getHostnameOverride(): Promise { + const hostname = await this.prefService.getStringPreference( + SysPreference.HostOverride, + ); + if (HasFailed(hostname)) { + this.logger.warn(hostname.print()); + return undefined; + } + + return hostname; + } +} diff --git a/backend/src/config/late/late-config.module.ts b/backend/src/config/late/late-config.module.ts index 6d3edbcd..04bc6c66 100644 --- a/backend/src/config/late/late-config.module.ts +++ b/backend/src/config/late/late-config.module.ts @@ -3,6 +3,7 @@ import { PreferenceDbModule } from '../../collections/preference-db/preference-d import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service'; import { EarlyConfigModule } from '../early/early-config.module'; import { EarlyJwtConfigService } from '../early/early-jwt.config.service'; +import { InfoConfigService } from './info.config.service'; import { JwtConfigService } from './jwt.config.service'; // This module contains all configservices that depend on the syspref module @@ -11,9 +12,9 @@ import { JwtConfigService } from './jwt.config.service'; // Otherwise we will create a circular depedency @Module({ - imports: [PreferenceDbModule, EarlyConfigModule], - providers: [JwtConfigService], - exports: [JwtConfigService, EarlyConfigModule], + imports: [EarlyConfigModule, PreferenceDbModule], + providers: [JwtConfigService, InfoConfigService], + exports: [EarlyConfigModule, JwtConfigService, InfoConfigService], }) export class LateConfigModule implements OnModuleInit { private readonly logger = new Logger(LateConfigModule.name); diff --git a/backend/src/routes/api/info/info.controller.ts b/backend/src/routes/api/info/info.controller.ts index 45cd9d86..71b199a0 100644 --- a/backend/src/routes/api/info/info.controller.ts +++ b/backend/src/routes/api/info/info.controller.ts @@ -2,17 +2,18 @@ import { Controller, Get } from '@nestjs/common'; import { AllFormatsResponse, AllPermissionsResponse, - InfoResponse, + InfoResponse } from 'picsur-shared/dist/dto/api/info.dto'; import { FileType2Ext, FileType2Mime, SupportedAnimFileTypes, - SupportedImageFileTypes, + SupportedImageFileTypes } from 'picsur-shared/dist/dto/mimes.dto'; import { TrackingState } from 'picsur-shared/dist/dto/tracking-state.enum'; import { FallbackIfFailed } from 'picsur-shared/dist/types'; import { HostConfigService } from '../../../config/early/host.config.service'; +import { InfoConfigService } from '../../../config/late/info.config.service'; import { NoPermissions } from '../../../decorators/permissions.decorator'; import { Returns } from '../../../decorators/returns.decorator'; import { UsageService } from '../../../managers/usage/usage.service'; @@ -23,21 +24,23 @@ import { PermissionsList } from '../../../models/constants/permissions.const'; export class InfoController { constructor( private readonly hostConfig: HostConfigService, + private readonly infoConfig: InfoConfigService, private readonly usageService: UsageService, ) {} @Get() @Returns(InfoResponse) async getInfo(): Promise { - const trackingID = FallbackIfFailed( - await this.usageService.getTrackingID(), - null, - ) ?? undefined; + const trackingID = + FallbackIfFailed(await this.usageService.getTrackingID(), null) ?? + undefined; + const hostOverride = await this.infoConfig.getHostnameOverride(); return { demo: this.hostConfig.isDemo(), production: this.hostConfig.isProduction(), version: this.hostConfig.getVersion(), + host_override: hostOverride, tracking: { id: trackingID, state: TrackingState.Detailed, diff --git a/backend/src/routes/api/info/info.module.ts b/backend/src/routes/api/info/info.module.ts index eb3d8c88..a8c0d2c1 100644 --- a/backend/src/routes/api/info/info.module.ts +++ b/backend/src/routes/api/info/info.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; -import { EarlyConfigModule } from '../../../config/early/early-config.module'; +import { LateConfigModule } from '../../../config/late/late-config.module'; import { UsageManagerModule } from '../../../managers/usage/usage.module'; import { InfoController } from './info.controller'; @Module({ - imports: [EarlyConfigModule, UsageManagerModule], + imports: [LateConfigModule, UsageManagerModule], controllers: [InfoController], }) export class InfoModule {} diff --git a/frontend/src/app/components/pref-option/pref-option.component.html b/frontend/src/app/components/pref-option/pref-option.component.html index 5b6611ab..ede0ae43 100644 --- a/frontend/src/app/components/pref-option/pref-option.component.html +++ b/frontend/src/app/components/pref-option/pref-option.component.html @@ -1,14 +1,13 @@ - +
{{ name }} + + {{ getErrorMessage() }} +
- +
{{ name }} + + {{ getErrorMessage() }} +
- +
{{ name }} - + No Yes @@ -91,6 +92,9 @@ help_outline + + {{ getErrorMessage() }} +
diff --git a/frontend/src/app/components/pref-option/pref-option.component.ts b/frontend/src/app/components/pref-option/pref-option.component.ts index 6190576c..c1a80489 100644 --- a/frontend/src/app/components/pref-option/pref-option.component.ts +++ b/frontend/src/app/components/pref-option/pref-option.component.ts @@ -1,15 +1,17 @@ import { Component, Input, OnInit } from '@angular/core'; +import { AbstractControl, FormControl, ValidationErrors } from '@angular/forms'; import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { DecodedPref, PrefValueType } from 'picsur-shared/dist/dto/preferences.dto'; import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; -import { Subject } from 'rxjs'; +import { filter } from 'rxjs'; import { Required } from 'src/app/models/decorators/required.decorator'; import { Logger } from 'src/app/services/logger/logger.service'; import { ErrorService } from 'src/app/util/error-manager/error.service'; import { Throttle } from 'src/app/util/throttle'; +import { ZodTypeAny } from 'zod'; @Component({ selector: 'pref-option', @@ -19,16 +21,28 @@ import { Throttle } from 'src/app/util/throttle'; export class PrefOptionComponent implements OnInit { private readonly logger = new Logger(PrefOptionComponent.name); - @Input() @Required pref: DecodedPref; + public formControl = new FormControl(undefined, { + updateOn: 'blur', + validators: this.syncValidator.bind(this), + }); + + private pref: DecodedPref; + @Input('pref') set prefSet(pref: DecodedPref) { + this.pref = pref; + this.formControl.setValue(pref.value); + } + get type() { + return this.pref.type; + } + @Input('update') @Required updateFunction: ( key: string, pref: PrefValueType, ) => AsyncFailable; - @Input() @Required name: string = ''; + @Input() @Required name: string = ''; @Input() helpText: string = ''; - - private updateSubject = new Subject(); + @Input() validator?: ZodTypeAny = undefined; constructor(private readonly errorService: ErrorService) {} @@ -36,44 +50,27 @@ export class PrefOptionComponent implements OnInit { this.subscribeUpdate(); } - get valString(): string { - if (this.pref.type !== 'string') { - throw new Error('Not a string preference'); - } - return this.pref.value as string; - } - - get valNumber(): number { - if (this.pref.type !== 'number') { - throw new Error('Not an int preference'); + getErrorMessage() { + if (this.formControl.errors) { + const errors = this.formControl.errors; + if (errors['error']) { + return errors['error']; + } + return 'Invalid value'; } - return this.pref.value as number; + return ''; } - get valBool(): boolean { - if (this.pref.type !== 'boolean') { - throw new Error('Not a boolean preference'); - } - return this.pref.value as boolean; - } + private syncValidator(control: AbstractControl): ValidationErrors | null { + if (!this.validator) return null; - update(value: any) { - this.updateSubject.next(value); - } + const result = this.validator.safeParse(control.value); - stringUpdateWrapper(e: Event) { - this.update((e.target as HTMLInputElement).value); - } - - numberUpdateWrapper(e: Event) { - const value = (e.target as HTMLInputElement).valueAsNumber; - if (isNaN(value)) return; - - this.update(value); - } + if (!result.success) { + return { error: result.error.issues[0]?.message ?? 'Invalid value' }; + } - booleanUpdateWrapper(e: boolean) { - this.update(e); + return null; } private async updatePreference(value: PrefValueType) { @@ -97,8 +94,11 @@ export class PrefOptionComponent implements OnInit { @AutoUnsubscribe() subscribeUpdate() { - return this.updateSubject - .pipe(Throttle(300)) + return this.formControl.valueChanges + .pipe( + filter((value) => this.formControl.errors === null), + Throttle(300), + ) .subscribe(this.updatePreference.bind(this)); } } diff --git a/frontend/src/app/components/pref-option/pref-option.module.ts b/frontend/src/app/components/pref-option/pref-option.module.ts index 79537913..7137851a 100644 --- a/frontend/src/app/components/pref-option/pref-option.module.ts +++ b/frontend/src/app/components/pref-option/pref-option.module.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; @@ -17,6 +18,7 @@ import { PrefOptionComponent } from './pref-option.component'; MatIconModule, MatTooltipModule, MatButtonModule, + ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, diff --git a/frontend/src/app/i18n/sys-pref.i18n.ts b/frontend/src/app/i18n/sys-pref.i18n.ts index f5e28c69..4aca002e 100644 --- a/frontend/src/app/i18n/sys-pref.i18n.ts +++ b/frontend/src/app/i18n/sys-pref.i18n.ts @@ -7,21 +7,10 @@ export const SysPreferenceUI: { category: string; }; } = { - [SysPreference.JwtSecret]: { - name: 'JWT Secret', - helpText: 'Secret used to sign JWT authentication tokens.', - category: 'Authentication', - }, - [SysPreference.JwtExpiresIn]: { - name: 'JWT Expiry Time', - helpText: 'Time before JWT authentication tokens expire.', - category: 'Authentication', - }, - [SysPreference.BCryptStrength]: { - name: 'BCrypt Strength', - helpText: - 'Strength of BCrypt hashing algorithm, 10 is recommended. Reduce this if running on a low powered device.', - category: 'Authentication', + [SysPreference.HostOverride]: { + name: 'Host Override', + helpText: 'Override the hostname for the server, useful for when you are accessing the server from a different domain.', + category: 'General', }, [SysPreference.RemoveDerivativesAfter]: { @@ -43,7 +32,6 @@ export const SysPreferenceUI: { category: 'Image Processing', }, - [SysPreference.ConversionTimeLimit]: { name: 'Convert/Edit Time Limit', helpText: @@ -57,6 +45,23 @@ export const SysPreferenceUI: { category: 'Image Processing', }, + [SysPreference.JwtSecret]: { + name: 'JWT Secret', + helpText: 'Secret used to sign JWT authentication tokens.', + category: 'Authentication', + }, + [SysPreference.JwtExpiresIn]: { + name: 'JWT Expiry Time', + helpText: 'Time before JWT authentication tokens expire.', + category: 'Authentication', + }, + [SysPreference.BCryptStrength]: { + name: 'BCrypt Strength', + helpText: + 'Strength of BCrypt hashing algorithm, 10 is recommended. Reduce this if running on a low powered device.', + category: 'Authentication', + }, + [SysPreference.EnableTracking]: { name: 'Enable Ackee Web Tracking', helpText: @@ -65,7 +70,7 @@ export const SysPreferenceUI: { }, [SysPreference.TrackingUrl]: { name: 'Ackee tracking URL', - helpText: 'URL of the Ackee tracking server.', + helpText: 'URL of the Ackee tracking server. Requests are proxied, so ensure the X-Forwarded-For header is handled.', category: 'Usage', }, [SysPreference.TrackingId]: { diff --git a/frontend/src/app/models/dto/server-info.dto.ts b/frontend/src/app/models/dto/server-info.dto.ts index c4ebda4b..2a662255 100644 --- a/frontend/src/app/models/dto/server-info.dto.ts +++ b/frontend/src/app/models/dto/server-info.dto.ts @@ -4,6 +4,7 @@ export class ServerInfo { production: boolean = false; demo: boolean = false; version: string = '0.0.0'; + host_override?: string; tracking: { state: TrackingState; id?: string; diff --git a/frontend/src/app/routes/settings/sharex/settings-sharex.component.ts b/frontend/src/app/routes/settings/sharex/settings-sharex.component.ts index d32d4088..cf8992ef 100644 --- a/frontend/src/app/routes/settings/sharex/settings-sharex.component.ts +++ b/frontend/src/app/routes/settings/sharex/settings-sharex.component.ts @@ -7,6 +7,7 @@ import { HasFailed } from 'picsur-shared/dist/types'; import { BehaviorSubject } from 'rxjs'; import { scan } from 'rxjs/operators'; import { ApiKeysService } from 'src/app/services/api/apikeys.service'; +import { InfoService } from 'src/app/services/api/info.service'; import { PermissionService } from 'src/app/services/api/permission.service'; import { Logger } from 'src/app/services/logger/logger.service'; import { ErrorService } from 'src/app/util/error-manager/error.service'; @@ -41,6 +42,7 @@ export class SettingsShareXComponent implements OnInit { constructor( private readonly apikeysService: ApiKeysService, private readonly permissionService: PermissionService, + private readonly infoService: InfoService, private readonly utilService: UtilService, private readonly errorService: ErrorService, ) {} @@ -66,7 +68,7 @@ export class SettingsShareXComponent implements OnInit { } const sharexConfig = BuildShareX( - this.utilService.getHost(), + this.infoService.getHostname(), this.key, '.' + ext, canUseDelete, diff --git a/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.html b/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.html index 414808ee..0ad1f1df 100644 --- a/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.html +++ b/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.html @@ -10,6 +10,7 @@

{{ category.category }}

[update]="sysPrefService.setPreference.bind(sysPrefService)" [name]="getName(pref.key)" [helpText]="getHelpText(pref.key)" + [validator]="getValidator(pref.key)" >
diff --git a/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.ts b/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.ts index a22f745c..b5eee771 100644 --- a/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.ts +++ b/frontend/src/app/routes/settings/sys-pref/settings-sys-pref.component.ts @@ -1,42 +1,54 @@ import { Component } from '@angular/core'; import { DecodedPref } from 'picsur-shared/dist/dto/preferences.dto'; -import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum'; +import { + SysPreference, + SysPreferenceValidators +} from 'picsur-shared/dist/dto/sys-preferences.enum'; import { map, Observable } from 'rxjs'; import { SysPreferenceUI } from 'src/app/i18n/sys-pref.i18n'; import { makeUnique } from 'picsur-shared/dist/util/unique'; import { SysPrefService } from 'src/app/services/api/sys-pref.service'; +import { z, ZodTypeAny } from 'zod'; @Component({ templateUrl: './settings-sys-pref.component.html', styleUrls: ['./settings-sys-pref.component.scss'], }) export class SettingsSysprefComponent { - private readonly syspreferenceUI = SysPreferenceUI; - public getName(key: string) { - return this.syspreferenceUI[key as SysPreference]?.name ?? key; + return SysPreferenceUI[key as SysPreference]?.name ?? key; } public getHelpText(key: string) { - return this.syspreferenceUI[key as SysPreference]?.helpText ?? ''; + return SysPreferenceUI[key as SysPreference]?.helpText ?? ''; } public getCategory(key: string): null | string { - return this.syspreferenceUI[key as SysPreference]?.category ?? null; + return SysPreferenceUI[key as SysPreference]?.category ?? null; + } + + public getValidator(key: string): ZodTypeAny { + return SysPreferenceValidators[key as SysPreference] ?? z.any(); } - preferences: Observable>; + preferences: Observable< + Array<{ category: string | null; prefs: DecodedPref[] }> + >; constructor(public readonly sysPrefService: SysPrefService) { this.preferences = sysPrefService.live.pipe( map((prefs) => { - const categories = makeUnique(prefs.map((pref) => this.getCategory(pref.key))); + const categories = makeUnique( + prefs.map((pref) => this.getCategory(pref.key)), + ); return categories.map((category) => ({ category, - prefs: prefs.filter((pref) => this.getCategory(pref.key) === category), + prefs: prefs.filter( + (pref) => this.getCategory(pref.key) === category, + ), })); }), - ) + ); } } diff --git a/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts index 56a1030d..cd6125b7 100644 --- a/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts +++ b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts @@ -17,11 +17,11 @@ import { ErrorService } from 'src/app/util/error-manager/error.service'; import { UtilService } from 'src/app/util/util.service'; import { CustomizeDialogComponent, - CustomizeDialogData, + CustomizeDialogData } from '../customize-dialog/customize-dialog.component'; import { EditDialogComponent, - EditDialogData, + EditDialogData } from '../edit-dialog/edit-dialog.component'; @Component({ diff --git a/frontend/src/app/routes/view/view.component.ts b/frontend/src/app/routes/view/view.component.ts index 6bdec21f..62416b62 100644 --- a/frontend/src/app/routes/view/view.component.ts +++ b/frontend/src/app/routes/view/view.component.ts @@ -3,7 +3,7 @@ import { ChangeDetectorRef, Component, OnDestroy, - OnInit, + OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ImageMetaResponse } from 'picsur-shared/dist/dto/api/image.dto'; @@ -11,7 +11,7 @@ import { ImageLinks } from 'picsur-shared/dist/dto/image-links.class'; import { AnimFileType, ImageFileType, - SupportedFileTypeCategory, + SupportedFileTypeCategory } from 'picsur-shared/dist/dto/mimes.dto'; import { EImage } from 'picsur-shared/dist/entities/image.entity'; import { EUser } from 'picsur-shared/dist/entities/user.entity'; @@ -85,12 +85,19 @@ export class ViewComponent implements OnInit, OnDestroy { ); } + private imageLinksCache: Record = {}; public get imageLinks(): ImageLinks { + if (this.imageLinksCache[this.selectedFormat] !== undefined) + return this.imageLinksCache[this.selectedFormat]; + const format = this.selectedFormat; - return this.imageService.CreateImageLinksFromID( + const links = this.imageService.CreateImageLinksFromID( this.id, format === 'original' ? null : format, ); + + this.imageLinksCache[format] = links; + return links; } async ngOnInit() { diff --git a/frontend/src/app/services/api/image.service.ts b/frontend/src/app/services/api/image.service.ts index 03a12b82..32c86bae 100644 --- a/frontend/src/app/services/api/image.service.ts +++ b/frontend/src/app/services/api/image.service.ts @@ -6,11 +6,11 @@ import { ImageListResponse, ImageUpdateRequest, ImageUpdateResponse, - ImageUploadResponse, + ImageUploadResponse } from 'picsur-shared/dist/dto/api/image-manage.dto'; import { ImageMetaResponse, - ImageRequestParams, + ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto'; import { ImageLinks } from 'picsur-shared/dist/dto/image-links.class'; import { FileType2Ext } from 'picsur-shared/dist/dto/mimes.dto'; @@ -21,11 +21,11 @@ import { FT, HasFailed, HasSuccess, - Open, + Open } from 'picsur-shared/dist/types/failable'; -import { UtilService } from 'src/app/util/util.service'; import { ImageUploadRequest } from '../../models/dto/image-upload-request.dto'; import { ApiService } from './api.service'; +import { InfoService } from './info.service'; import { UserService } from './user.service'; @Injectable({ @@ -34,7 +34,7 @@ import { UserService } from './user.service'; export class ImageService { constructor( private readonly api: ApiService, - private readonly util: UtilService, + private readonly infoService: InfoService, private readonly userService: UserService, ) {} @@ -126,7 +126,7 @@ export class ImageService { // Non api calls public GetImageURL(image: string, filetype: string | null): string { - const baseURL = this.util.getHost(); + const baseURL = this.infoService.getHostname(); const extension = FileType2Ext(filetype ?? ''); return `${baseURL}/i/${image}${ diff --git a/frontend/src/app/services/api/info.service.ts b/frontend/src/app/services/api/info.service.ts index d23a65bc..5902383c 100644 --- a/frontend/src/app/services/api/info.service.ts +++ b/frontend/src/app/services/api/info.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@angular/core'; +import { Inject, Injectable } from '@angular/core'; +import { LOCATION } from '@ng-web-apis/common'; import { InfoResponse } from 'picsur-shared/dist/dto/api/info.dto'; import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; import { SemVerRegex } from 'picsur-shared/dist/util/common-regex'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, filter, Observable, take } from 'rxjs'; import pkg from '../../../../package.json'; import { ServerInfo } from '../../models/dto/server-info.dto'; import { Logger } from '../logger/logger.service'; @@ -24,7 +25,12 @@ export class InfoService { private infoSubject = new BehaviorSubject(new ServerInfo()); - constructor(private readonly api: ApiService) {} + constructor( + private readonly api: ApiService, + @Inject(LOCATION) private readonly location: Location, + ) { + this.pollInfo().catch((e) => this.logger.warn(e)); + } public async pollInfo(): AsyncFailable { const response = await this.api.get(InfoResponse, '/api/info'); @@ -34,10 +40,34 @@ export class InfoService { return response; } + public async getLoadedSnapshot(): Promise { + if (this.isLoaded()) { + return this.snapshot; + } + + return new Promise((resolve) => { + const filtered = this.live.pipe( + filter((info) => info.version !== '0.0.0'), + take(1), + ); + (filtered as Observable).subscribe(resolve); + }); + } + public getFrontendVersion(): string { return pkg.version; } + public getHostname(): string { + // const info = await this.getLoadedSnapshot(); + + // if (info.host_override !== undefined) { + // return info.host_override; + // } + + return this.location.protocol + '//' + this.location.host; + } + // If either version starts with 0. it has to be exactly the same // If both versions start with something else, they have to match the first part public async isCompatibleWithServer(): AsyncFailable { @@ -67,4 +97,8 @@ export class InfoService { return serverDecoded[0] === clientDecoded[0]; } } + + public isLoaded(): boolean { + return this.snapshot.version !== '0.0.0'; + } } diff --git a/frontend/src/app/util/util.service.ts b/frontend/src/app/util/util.service.ts index bf18354c..69bd72f9 100644 --- a/frontend/src/app/util/util.service.ts +++ b/frontend/src/app/util/util.service.ts @@ -1,8 +1,7 @@ -import { Inject, Injectable } from '@angular/core'; -import { LOCATION } from '@ng-web-apis/common'; +import { Injectable } from '@angular/core'; import { FileType2Ext, - SupportedFileTypes, + SupportedFileTypes } from 'picsur-shared/dist/dto/mimes.dto'; import { HasFailed } from 'picsur-shared/dist/types'; import { Logger } from '../services/logger/logger.service'; @@ -13,12 +12,6 @@ import { Logger } from '../services/logger/logger.service'; export class UtilService { private readonly logger = new Logger(UtilService.name); - constructor(@Inject(LOCATION) private readonly location: Location) {} - - public getHost(): string { - return this.location.protocol + '//' + this.location.host; - } - public downloadBuffer( buffer: ArrayBuffer | string, filename: string, diff --git a/shared/src/dto/api/info.dto.ts b/shared/src/dto/api/info.dto.ts index f052365d..4653f7b3 100644 --- a/shared/src/dto/api/info.dto.ts +++ b/shared/src/dto/api/info.dto.ts @@ -9,6 +9,7 @@ export const InfoResponseSchema = z.object({ production: z.boolean(), demo: z.boolean(), version: string().regex(SemVerRegex), + host_override: z.string().optional(), tracking: z.object({ state: TrackingStateSchema, id: IsEntityID().optional(), diff --git a/shared/src/dto/sys-preferences.enum.ts b/shared/src/dto/sys-preferences.enum.ts index 4d881158..5bc45364 100644 --- a/shared/src/dto/sys-preferences.enum.ts +++ b/shared/src/dto/sys-preferences.enum.ts @@ -1,13 +1,14 @@ -import { PrefValueTypeStrings } from './preferences.dto'; -import ms from 'ms'; -import { IsValidMS } from '../validators/ms.validator'; -import { URLRegex, UUIDRegex } from '../util/common-regex'; import { z } from 'zod'; -import { IsPosInt } from '../validators/positive-int.validator'; +import { HostNameRegex, URLRegex } from '../util/common-regex'; import { IsEntityID } from '../validators/entity-id.validator'; +import { IsValidMS } from '../validators/ms.validator'; +import { IsPosInt } from '../validators/positive-int.validator'; +import { PrefValueTypeStrings } from './preferences.dto'; // This enum is only here to make accessing the values easier, and type checking in the backend export enum SysPreference { + HostOverride = 'host_override', + JwtSecret = 'jwt_secret', JwtExpiresIn = 'jwt_expires_in', BCryptStrength = 'bcrypt_strength', @@ -33,6 +34,8 @@ export const SysPreferenceList: string[] = Object.values(SysPreference); export const SysPreferenceValueTypes: { [key in SysPreference]: PrefValueTypeStrings; } = { + [SysPreference.HostOverride]: 'string', + [SysPreference.JwtSecret]: 'string', [SysPreference.JwtExpiresIn]: 'string', [SysPreference.BCryptStrength]: 'number', @@ -54,6 +57,11 @@ export const SysPreferenceValueTypes: { export const SysPreferenceValidators: { [key in SysPreference]: z.ZodTypeAny; } = { + [SysPreference.HostOverride]: z + .string() + .regex(HostNameRegex) + .or(z.literal('')), + [SysPreference.JwtSecret]: z.boolean(), [SysPreference.JwtExpiresIn]: IsValidMS(), @@ -66,8 +74,8 @@ export const SysPreferenceValidators: { [SysPreference.ConversionMemoryLimit]: IsPosInt(), [SysPreference.EnableTracking]: z.boolean(), - [SysPreference.TrackingUrl]: z.string().regex(URLRegex), - [SysPreference.TrackingId]: IsEntityID(), + [SysPreference.TrackingUrl]: z.string().regex(URLRegex).or(z.literal('')), + [SysPreference.TrackingId]: IsEntityID().or(z.literal('')), [SysPreference.EnableTelemetry]: z.boolean(), }; diff --git a/shared/src/dto/usr-preferences.enum.ts b/shared/src/dto/usr-preferences.enum.ts index 8eb626c1..e30c5c28 100644 --- a/shared/src/dto/usr-preferences.enum.ts +++ b/shared/src/dto/usr-preferences.enum.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import { PrefValueTypeStrings } from './preferences.dto'; // This enum is only here to make accessing the values easier, and type checking in the backend @@ -16,7 +17,7 @@ export const UsrPreferenceValueTypes: { }; export const UsrPreferenceValidators: { - [key in UsrPreference]: (value: any) => boolean; + [key in UsrPreference]: z.ZodTypeAny; } = { - [UsrPreference.KeepOriginal]: (value: any) => typeof value === 'boolean', + [UsrPreference.KeepOriginal]: z.boolean(), }; diff --git a/shared/src/util/common-regex.ts b/shared/src/util/common-regex.ts index 2dd7f5dd..92c4a0e0 100644 --- a/shared/src/util/common-regex.ts +++ b/shared/src/util/common-regex.ts @@ -5,3 +5,4 @@ export const URLRegex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/; export const UUIDRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +export const HostNameRegex = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/; diff --git a/shared/src/validators/ms.validator.ts b/shared/src/validators/ms.validator.ts index 06c046d3..bcbfb2a5 100644 --- a/shared/src/validators/ms.validator.ts +++ b/shared/src/validators/ms.validator.ts @@ -2,4 +2,20 @@ import ms from 'ms'; import { z } from 'zod'; export const IsValidMS = () => - z.preprocess((v) => ms(v as any), z.number().int().min(0)); + z.preprocess( + (v: any) => { + try { + return ms(v); + } catch (e) { + return NaN; + } + }, + z + .number({ + errorMap: () => ({ + message: 'Invalid duration value', + }), + }) + .int() + .min(0), + );