diff --git a/backend/src/collections/image-db/image-file-db.service.ts b/backend/src/collections/image-db/image-file-db.service.ts index d602774..9878907 100644 --- a/backend/src/collections/image-db/image-file-db.service.ts +++ b/backend/src/collections/image-db/image-file-db.service.ts @@ -45,7 +45,7 @@ export class ImageFileDBService { ): AsyncFailable { try { const found = await this.imageFileRepo.findOne({ - where: { imageId, type }, + where: { imageId: imageId ?? '', type: type ?? '' }, }); if (!found) return Fail('Image not found'); diff --git a/backend/src/decorators/image-id/image-full-id.decorator.ts b/backend/src/decorators/image-id/image-full-id.decorator.ts new file mode 100644 index 0000000..77f6f12 --- /dev/null +++ b/backend/src/decorators/image-id/image-full-id.decorator.ts @@ -0,0 +1,6 @@ +import { Param, PipeTransform, Type } from '@nestjs/common'; +import { ImageFullIdPipe } from './image-full-id.pipe'; + +export const ImageFullIdParam = ( + ...pipes: (PipeTransform | Type>)[] +) => Param('id', ImageFullIdPipe, ...pipes); diff --git a/backend/src/decorators/image-id/image-full-id.pipe.ts b/backend/src/decorators/image-id/image-full-id.pipe.ts new file mode 100644 index 0000000..f0f0e5e --- /dev/null +++ b/backend/src/decorators/image-id/image-full-id.pipe.ts @@ -0,0 +1,29 @@ +import { + ArgumentMetadata, + BadRequestException, + Injectable, + PipeTransform +} from '@nestjs/common'; +import { Ext2Mime } from 'picsur-shared/dist/dto/mimes.dto'; +import { UUIDRegex } from 'picsur-shared/dist/util/common-regex'; +import { ImageFullId } from '../../models/constants/image-full-id.const'; + +@Injectable() +export class ImageFullIdPipe implements PipeTransform { + transform(value: string, metadata: ArgumentMetadata): ImageFullId { + const split = value.split('.'); + if (split.length !== 2) + throw new BadRequestException('Invalid image identifier'); + + const [id, ext] = split; + if (!UUIDRegex.test(id)) + throw new BadRequestException('Invalid image identifier'); + + const mime = Ext2Mime(ext); + + if (mime === undefined) + throw new BadRequestException('Invalid image identifier'); + + return { id, ext, mime }; + } +} diff --git a/backend/src/models/constants/image-full-id.const.ts b/backend/src/models/constants/image-full-id.const.ts new file mode 100644 index 0000000..b5f7615 --- /dev/null +++ b/backend/src/models/constants/image-full-id.const.ts @@ -0,0 +1,5 @@ +export interface ImageFullId { + id: string; + ext: string; + mime: string; +} diff --git a/backend/src/routes/image/image.controller.ts b/backend/src/routes/image/image.controller.ts index 5024991..dd08ceb 100644 --- a/backend/src/routes/image/image.controller.ts +++ b/backend/src/routes/image/image.controller.ts @@ -11,12 +11,14 @@ import { import { FastifyReply } from 'fastify'; import { ImageMetaResponse } from 'picsur-shared/dist/dto/api/image.dto'; import { HasFailed } from 'picsur-shared/dist/types'; +import { ImageFullIdParam } from '../../decorators/image-id/image-full-id.decorator'; import { ImageIdParam } from '../../decorators/image-id/image-id.decorator'; import { MultiPart } from '../../decorators/multipart/multipart.decorator'; import { RequiredPermissions } from '../../decorators/permissions.decorator'; import { ReqUserID } from '../../decorators/request-user.decorator'; import { Returns } from '../../decorators/returns.decorator'; import { ImageManagerService } from '../../managers/image/image.service'; +import { ImageFullId } from '../../models/constants/image-full-id.const'; import { Permission } from '../../models/constants/permissions.const'; import { ImageUploadDto } from '../../models/dto/image-upload.dto'; @@ -33,9 +35,9 @@ export class ImageController { // Usually passthrough is for manually sending the response, // But we need it here to set the mime type @Res({ passthrough: true }) res: FastifyReply, - @ImageIdParam() id: string, + @ImageFullIdParam() fullid: ImageFullId, ): Promise { - const image = await this.imagesService.getMaster(id); + const image = await this.imagesService.getMaster(fullid.id); if (HasFailed(image)) { this.logger.warn(image.getReason()); throw new NotFoundException('Could not find image'); @@ -48,9 +50,9 @@ export class ImageController { @Head(':id') async headImage( @Res({ passthrough: true }) res: FastifyReply, - @ImageIdParam() id: string, + @ImageFullIdParam() fullid: ImageFullId, ) { - const fullmime = await this.imagesService.getMasterMime(id); + const fullmime = await this.imagesService.getMasterMime(fullid.id); if (HasFailed(fullmime)) { this.logger.warn(fullmime.getReason()); throw new NotFoundException('Could not find image'); diff --git a/frontend/src/app/components/picsur-img/picsur-img.component.html b/frontend/src/app/components/picsur-img/picsur-img.component.html index 5fe73ce..2e28007 100644 --- a/frontend/src/app/components/picsur-img/picsur-img.component.html +++ b/frontend/src/app/components/picsur-img/picsur-img.component.html @@ -7,3 +7,4 @@ > +broken_image diff --git a/frontend/src/app/components/picsur-img/picsur-img.component.ts b/frontend/src/app/components/picsur-img/picsur-img.component.ts index 8100076..895d6a5 100644 --- a/frontend/src/app/components/picsur-img/picsur-img.component.ts +++ b/frontend/src/app/components/picsur-img/picsur-img.component.ts @@ -18,6 +18,7 @@ enum PicsurImgState { Loading = 'loading', Canvas = 'canvas', Image = 'image', + Error = 'error', } @Component({ @@ -41,22 +42,28 @@ export class PicsurImgComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { let url = this.imageURL ?? ''; - if (URLRegex.test(url)) { - this.update(url).catch(this.logger.error); - } else { + if (!URLRegex.test(url)) { this.state = PicsurImgState.Loading; - } - } - - private async update(url: string) { - const mime = await this.getMime(url); - if (HasFailed(mime)) { - this.logger.error(mime.getReason()); return; } + this.update(url) + .then((result) => { + if (HasFailed(result)) { + this.state = PicsurImgState.Error; + this.logger.error(result.getReason()); + } + }) + .catch((e) => this.logger.error); + } + + private async update(url: string): AsyncFailable { + const mime = await this.getMime(url); + if (HasFailed(mime)) return mime; + if (mime.mime === SupportedMime.QOI) { const result = await this.qoiWorker.decode(url); + if (HasFailed(result)) return result; const canvas = this.canvas.nativeElement; canvas.height = result.height; diff --git a/frontend/src/app/components/picsur-img/picsur-img.module.ts b/frontend/src/app/components/picsur-img/picsur-img.module.ts index 18ad473..1e7ca77 100644 --- a/frontend/src/app/components/picsur-img/picsur-img.module.ts +++ b/frontend/src/app/components/picsur-img/picsur-img.module.ts @@ -1,11 +1,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { PicsurImgComponent } from './picsur-img.component'; @NgModule({ declarations: [PicsurImgComponent], - imports: [CommonModule, MatProgressSpinnerModule], + imports: [CommonModule, MatProgressSpinnerModule, MatIconModule], exports: [PicsurImgComponent], }) export class PicsurImgModule {} diff --git a/frontend/src/app/routes/view/view.component.ts b/frontend/src/app/routes/view/view.component.ts index 386b94c..472a881 100644 --- a/frontend/src/app/routes/view/view.component.ts +++ b/frontend/src/app/routes/view/view.component.ts @@ -32,7 +32,7 @@ export class ViewComponent implements OnInit { return this.utilService.quitError(metadata.getReason()); } - this.imageLinks = this.imageService.CreateImageLinksFromID(id); + this.imageLinks = this.imageService.CreateImageLinksFromID(id, 'qoi'); } download() { diff --git a/frontend/src/app/services/api/api.service.ts b/frontend/src/app/services/api/api.service.ts index 6d2653a..6aef7d3 100644 --- a/frontend/src/app/services/api/api.service.ts +++ b/frontend/src/app/services/api/api.service.ts @@ -120,6 +120,8 @@ export class ApiService { const response = await this.fetch(url, options); if (HasFailed(response)) return response; + if (!response.ok) return Fail('Recieved a non-ok response'); + const mimeType = response.headers.get('Content-Type') ?? 'other/unknown'; let name = response.headers.get('Content-Disposition'); if (!name) { @@ -155,6 +157,8 @@ export class ApiService { const response = await this.fetch(url, options); if (HasFailed(response)) return response; + if (!response.ok) return Fail('Recieved a non-ok response'); + return response.headers; } diff --git a/frontend/src/app/services/api/image.service.ts b/frontend/src/app/services/api/image.service.ts index ad1f467..cbb97b1 100644 --- a/frontend/src/app/services/api/image.service.ts +++ b/frontend/src/app/services/api/image.service.ts @@ -42,7 +42,7 @@ export class ImageService { }; } - public CreateImageLinksFromID(imageID: string): ImageLinks { - return this.CreateImageLinks(this.GetImageURL(imageID)); + public CreateImageLinksFromID(imageID: string, format: string): ImageLinks { + return this.CreateImageLinks(this.GetImageURL(imageID + '.' + format)); } } diff --git a/frontend/src/app/workers/qoi-worker.dto.ts b/frontend/src/app/workers/qoi-worker.dto.ts index 31eeb6b..2053060 100644 --- a/frontend/src/app/workers/qoi-worker.dto.ts +++ b/frontend/src/app/workers/qoi-worker.dto.ts @@ -1,3 +1,5 @@ +import { AsyncFailable, Failable } from 'picsur-shared/dist/types'; + export interface QOIImage { data: ImageData; width: number; @@ -10,8 +12,9 @@ export interface QOIWorkerIn { authorization: string; } -export interface QOIWorkerOut extends QOIImage { +export interface QOIWorkerOut { id: number; + result: Failable; } -export type QOIJob = (url: string, authorization: string) => Promise; +export type QOIJob = (url: string, authorization: string) => AsyncFailable; diff --git a/frontend/src/app/workers/qoi-worker.service.ts b/frontend/src/app/workers/qoi-worker.service.ts index 4a034c0..8d8b932 100644 --- a/frontend/src/app/workers/qoi-worker.service.ts +++ b/frontend/src/app/workers/qoi-worker.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { AsyncFailable, Failure, HasFailed } from 'picsur-shared/dist/types'; import { KeyService } from '../services/storage/key.service'; import { QOIImage, QOIJob, QOIWorkerOut } from './qoi-worker.dto'; @@ -17,7 +18,7 @@ export class QoiWorkerService { } } - public async decode(url: string): Promise { + public async decode(url: string): AsyncFailable { const authorization = 'Bearer ' + (this.keyService.get() ?? ''); if (this.worker && !this.job) { @@ -27,11 +28,10 @@ export class QoiWorkerService { if (data.id !== id) return; this.worker!.removeEventListener('message', listener); - resolve({ - data: data.data, - width: data.width, - height: data.height, - }); + let result = data.result; + + if (HasFailed(result)) result = Failure.deserialize(data.result); + resolve(result); }; this.worker!.addEventListener('message', listener); this.worker!.postMessage({ id, url, authorization }); diff --git a/frontend/src/app/workers/qoi.job.ts b/frontend/src/app/workers/qoi.job.ts index bd24871..80693c1 100644 --- a/frontend/src/app/workers/qoi.job.ts +++ b/frontend/src/app/workers/qoi.job.ts @@ -1,32 +1,37 @@ +import { AsyncFailable, Fail } from 'picsur-shared/dist/types'; import { QOIdecodeJS } from '../util/qoi/qoi-decode'; import { QOIImage } from './qoi-worker.dto'; export default async function qoiDecodeJob( url: string, authorization: string -): Promise { - const response = await fetch(url, { - headers: { - Authorization: authorization, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${url}`); +): AsyncFailable { + try { + const response = await fetch(url, { + headers: { + Authorization: authorization, + }, + }); + if (!response.ok) { + return Fail('Could not fetch image'); + } + + const buffer = await response.arrayBuffer(); + + const image = QOIdecodeJS(buffer, null, null, 4); + + const imageData = new ImageData( + new Uint8ClampedArray(image.data.buffer), + image.width, + image.height + ); + + return { + data: imageData, + width: image.width, + height: image.height, + }; + } catch (e) { + return Fail(e); } - - const buffer = await response.arrayBuffer(); - - const image = QOIdecodeJS(buffer, null, null, 4); - - const imageData = new ImageData( - new Uint8ClampedArray(image.data.buffer), - image.width, - image.height - ); - - return { - data: imageData, - width: image.width, - height: image.height, - }; } diff --git a/frontend/src/app/workers/qoi.worker.ts b/frontend/src/app/workers/qoi.worker.ts index b631f13..e259e39 100644 --- a/frontend/src/app/workers/qoi.worker.ts +++ b/frontend/src/app/workers/qoi.worker.ts @@ -14,7 +14,7 @@ addEventListener('message', async (msg) => { const returned: QOIWorkerOut = { id, - ...result, + result, }; postMessage(returned); diff --git a/shared/src/types/failable.ts b/shared/src/types/failable.ts index 78fd335..0791a80 100644 --- a/shared/src/types/failable.ts +++ b/shared/src/types/failable.ts @@ -4,7 +4,12 @@ // -> Side effects go brrr export class Failure { - constructor(private readonly reason?: string, private readonly stack?: string) {} + private __68351953531423479708__id_failure = 1148363914; + + constructor( + private readonly reason?: string, + private readonly stack?: string, + ) {} getReason(): string { return this.reason ?? 'Unknown'; @@ -13,14 +18,22 @@ export class Failure { getStack(): string { return this.stack ?? 'None'; } + + static deserialize(data: any): Failure { + if (data.__68351953531423479708__id_failure !== 1148363914) { + throw new Error('Invalid failure data'); + } + + return new Failure(data.reason, data.stack); + } } export function Fail(reason?: any): Failure { if (typeof reason === 'string') { return new Failure(reason); - } else if(reason instanceof Error) { + } else if (reason instanceof Error) { return new Failure(reason.message, reason.stack); - } else if(reason instanceof Failure) { + } else if (reason instanceof Failure) { return reason; } else { return new Failure('Converted(' + reason + ')'); @@ -33,20 +46,26 @@ export type AsyncFailable = Promise>; export function HasFailed(failable: Failable): failable is Failure { if (failable instanceof Promise) throw new Error('Invalid use of HasFailed'); - return failable instanceof Failure; + return (failable as any).__68351953531423479708__id_failure === 1148363914; } export function HasSuccess(failable: Failable): failable is T { if (failable instanceof Promise) throw new Error('Invalid use of HasSuccess'); - return !(failable instanceof Failure); + return (failable as any).__68351953531423479708__id_failure !== 1148363914; } -export function Map(failable: Failable, mapper: (value: T) => U): Failable { +export function Map( + failable: Failable, + mapper: (value: T) => U, +): Failable { if (HasFailed(failable)) return failable; return mapper(failable); } -export function Open(failable: Failable, key: U): Failable { +export function Open( + failable: Failable, + key: U, +): Failable { if (HasFailed(failable)) return failable; return failable[key]; }