add ability to add extensions to image api

This commit is contained in:
rubikscraft 2022-04-21 19:35:11 +02:00
parent 1d7a18b2bb
commit 6c1fa47a77
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
16 changed files with 141 additions and 59 deletions

View file

@ -45,7 +45,7 @@ export class ImageFileDBService {
): AsyncFailable<EImageFileBackend> { ): AsyncFailable<EImageFileBackend> {
try { try {
const found = await this.imageFileRepo.findOne({ const found = await this.imageFileRepo.findOne({
where: { imageId, type }, where: { imageId: imageId ?? '', type: type ?? '' },
}); });
if (!found) return Fail('Image not found'); if (!found) return Fail('Image not found');

View file

@ -0,0 +1,6 @@
import { Param, PipeTransform, Type } from '@nestjs/common';
import { ImageFullIdPipe } from './image-full-id.pipe';
export const ImageFullIdParam = (
...pipes: (PipeTransform<any, any> | Type<PipeTransform<any, any>>)[]
) => Param('id', ImageFullIdPipe, ...pipes);

View file

@ -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<string, ImageFullId> {
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 };
}
}

View file

@ -0,0 +1,5 @@
export interface ImageFullId {
id: string;
ext: string;
mime: string;
}

View file

@ -11,12 +11,14 @@ import {
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
import { ImageMetaResponse } from 'picsur-shared/dist/dto/api/image.dto'; import { ImageMetaResponse } from 'picsur-shared/dist/dto/api/image.dto';
import { HasFailed } from 'picsur-shared/dist/types'; 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 { ImageIdParam } from '../../decorators/image-id/image-id.decorator';
import { MultiPart } from '../../decorators/multipart/multipart.decorator'; import { MultiPart } from '../../decorators/multipart/multipart.decorator';
import { RequiredPermissions } from '../../decorators/permissions.decorator'; import { RequiredPermissions } from '../../decorators/permissions.decorator';
import { ReqUserID } from '../../decorators/request-user.decorator'; import { ReqUserID } from '../../decorators/request-user.decorator';
import { Returns } from '../../decorators/returns.decorator'; import { Returns } from '../../decorators/returns.decorator';
import { ImageManagerService } from '../../managers/image/image.service'; import { ImageManagerService } from '../../managers/image/image.service';
import { ImageFullId } from '../../models/constants/image-full-id.const';
import { Permission } from '../../models/constants/permissions.const'; import { Permission } from '../../models/constants/permissions.const';
import { ImageUploadDto } from '../../models/dto/image-upload.dto'; import { ImageUploadDto } from '../../models/dto/image-upload.dto';
@ -33,9 +35,9 @@ export class ImageController {
// Usually passthrough is for manually sending the response, // Usually passthrough is for manually sending the response,
// But we need it here to set the mime type // But we need it here to set the mime type
@Res({ passthrough: true }) res: FastifyReply, @Res({ passthrough: true }) res: FastifyReply,
@ImageIdParam() id: string, @ImageFullIdParam() fullid: ImageFullId,
): Promise<Buffer> { ): Promise<Buffer> {
const image = await this.imagesService.getMaster(id); const image = await this.imagesService.getMaster(fullid.id);
if (HasFailed(image)) { if (HasFailed(image)) {
this.logger.warn(image.getReason()); this.logger.warn(image.getReason());
throw new NotFoundException('Could not find image'); throw new NotFoundException('Could not find image');
@ -48,9 +50,9 @@ export class ImageController {
@Head(':id') @Head(':id')
async headImage( async headImage(
@Res({ passthrough: true }) res: FastifyReply, @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)) { if (HasFailed(fullmime)) {
this.logger.warn(fullmime.getReason()); this.logger.warn(fullmime.getReason());
throw new NotFoundException('Could not find image'); throw new NotFoundException('Could not find image');

View file

@ -7,3 +7,4 @@
></canvas> ></canvas>
<img *ngIf="state === 'image'" [src]="imageURL" /> <img *ngIf="state === 'image'" [src]="imageURL" />
<mat-spinner *ngIf="state === 'loading'" color="accent"></mat-spinner> <mat-spinner *ngIf="state === 'loading'" color="accent"></mat-spinner>
<mat-icon *ngIf="state === 'error'">broken_image</mat-icon>

View file

@ -18,6 +18,7 @@ enum PicsurImgState {
Loading = 'loading', Loading = 'loading',
Canvas = 'canvas', Canvas = 'canvas',
Image = 'image', Image = 'image',
Error = 'error',
} }
@Component({ @Component({
@ -41,22 +42,28 @@ export class PicsurImgComponent implements OnChanges {
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
let url = this.imageURL ?? ''; let url = this.imageURL ?? '';
if (URLRegex.test(url)) { if (!URLRegex.test(url)) {
this.update(url).catch(this.logger.error);
} else {
this.state = PicsurImgState.Loading; this.state = PicsurImgState.Loading;
}
}
private async update(url: string) {
const mime = await this.getMime(url);
if (HasFailed(mime)) {
this.logger.error(mime.getReason());
return; 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<void> {
const mime = await this.getMime(url);
if (HasFailed(mime)) return mime;
if (mime.mime === SupportedMime.QOI) { if (mime.mime === SupportedMime.QOI) {
const result = await this.qoiWorker.decode(url); const result = await this.qoiWorker.decode(url);
if (HasFailed(result)) return result;
const canvas = this.canvas.nativeElement; const canvas = this.canvas.nativeElement;
canvas.height = result.height; canvas.height = result.height;

View file

@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PicsurImgComponent } from './picsur-img.component'; import { PicsurImgComponent } from './picsur-img.component';
@NgModule({ @NgModule({
declarations: [PicsurImgComponent], declarations: [PicsurImgComponent],
imports: [CommonModule, MatProgressSpinnerModule], imports: [CommonModule, MatProgressSpinnerModule, MatIconModule],
exports: [PicsurImgComponent], exports: [PicsurImgComponent],
}) })
export class PicsurImgModule {} export class PicsurImgModule {}

View file

@ -32,7 +32,7 @@ export class ViewComponent implements OnInit {
return this.utilService.quitError(metadata.getReason()); return this.utilService.quitError(metadata.getReason());
} }
this.imageLinks = this.imageService.CreateImageLinksFromID(id); this.imageLinks = this.imageService.CreateImageLinksFromID(id, 'qoi');
} }
download() { download() {

View file

@ -120,6 +120,8 @@ export class ApiService {
const response = await this.fetch(url, options); const response = await this.fetch(url, options);
if (HasFailed(response)) return response; if (HasFailed(response)) return response;
if (!response.ok) return Fail('Recieved a non-ok response');
const mimeType = response.headers.get('Content-Type') ?? 'other/unknown'; const mimeType = response.headers.get('Content-Type') ?? 'other/unknown';
let name = response.headers.get('Content-Disposition'); let name = response.headers.get('Content-Disposition');
if (!name) { if (!name) {
@ -155,6 +157,8 @@ export class ApiService {
const response = await this.fetch(url, options); const response = await this.fetch(url, options);
if (HasFailed(response)) return response; if (HasFailed(response)) return response;
if (!response.ok) return Fail('Recieved a non-ok response');
return response.headers; return response.headers;
} }

View file

@ -42,7 +42,7 @@ export class ImageService {
}; };
} }
public CreateImageLinksFromID(imageID: string): ImageLinks { public CreateImageLinksFromID(imageID: string, format: string): ImageLinks {
return this.CreateImageLinks(this.GetImageURL(imageID)); return this.CreateImageLinks(this.GetImageURL(imageID + '.' + format));
} }
} }

View file

@ -1,3 +1,5 @@
import { AsyncFailable, Failable } from 'picsur-shared/dist/types';
export interface QOIImage { export interface QOIImage {
data: ImageData; data: ImageData;
width: number; width: number;
@ -10,8 +12,9 @@ export interface QOIWorkerIn {
authorization: string; authorization: string;
} }
export interface QOIWorkerOut extends QOIImage { export interface QOIWorkerOut {
id: number; id: number;
result: Failable<QOIImage>;
} }
export type QOIJob = (url: string, authorization: string) => Promise<QOIImage>; export type QOIJob = (url: string, authorization: string) => AsyncFailable<QOIImage>;

View file

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { AsyncFailable, Failure, HasFailed } from 'picsur-shared/dist/types';
import { KeyService } from '../services/storage/key.service'; import { KeyService } from '../services/storage/key.service';
import { QOIImage, QOIJob, QOIWorkerOut } from './qoi-worker.dto'; import { QOIImage, QOIJob, QOIWorkerOut } from './qoi-worker.dto';
@ -17,7 +18,7 @@ export class QoiWorkerService {
} }
} }
public async decode(url: string): Promise<QOIImage> { public async decode(url: string): AsyncFailable<QOIImage> {
const authorization = 'Bearer ' + (this.keyService.get() ?? ''); const authorization = 'Bearer ' + (this.keyService.get() ?? '');
if (this.worker && !this.job) { if (this.worker && !this.job) {
@ -27,11 +28,10 @@ export class QoiWorkerService {
if (data.id !== id) return; if (data.id !== id) return;
this.worker!.removeEventListener('message', listener); this.worker!.removeEventListener('message', listener);
resolve({ let result = data.result;
data: data.data,
width: data.width, if (HasFailed(result)) result = Failure.deserialize(data.result);
height: data.height, resolve(result);
});
}; };
this.worker!.addEventListener('message', listener); this.worker!.addEventListener('message', listener);
this.worker!.postMessage({ id, url, authorization }); this.worker!.postMessage({ id, url, authorization });

View file

@ -1,32 +1,37 @@
import { AsyncFailable, Fail } from 'picsur-shared/dist/types';
import { QOIdecodeJS } from '../util/qoi/qoi-decode'; import { QOIdecodeJS } from '../util/qoi/qoi-decode';
import { QOIImage } from './qoi-worker.dto'; import { QOIImage } from './qoi-worker.dto';
export default async function qoiDecodeJob( export default async function qoiDecodeJob(
url: string, url: string,
authorization: string authorization: string
): Promise<QOIImage> { ): AsyncFailable<QOIImage> {
const response = await fetch(url, { try {
headers: { const response = await fetch(url, {
Authorization: authorization, headers: {
}, Authorization: authorization,
}); },
if (!response.ok) { });
throw new Error(`Failed to fetch image: ${url}`); 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,
};
} }

View file

@ -14,7 +14,7 @@ addEventListener('message', async (msg) => {
const returned: QOIWorkerOut = { const returned: QOIWorkerOut = {
id, id,
...result, result,
}; };
postMessage(returned); postMessage(returned);

View file

@ -4,7 +4,12 @@
// -> Side effects go brrr // -> Side effects go brrr
export class Failure { 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 { getReason(): string {
return this.reason ?? 'Unknown'; return this.reason ?? 'Unknown';
@ -13,14 +18,22 @@ export class Failure {
getStack(): string { getStack(): string {
return this.stack ?? 'None'; 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 { export function Fail(reason?: any): Failure {
if (typeof reason === 'string') { if (typeof reason === 'string') {
return new Failure(reason); return new Failure(reason);
} else if(reason instanceof Error) { } else if (reason instanceof Error) {
return new Failure(reason.message, reason.stack); return new Failure(reason.message, reason.stack);
} else if(reason instanceof Failure) { } else if (reason instanceof Failure) {
return reason; return reason;
} else { } else {
return new Failure('Converted(' + reason + ')'); return new Failure('Converted(' + reason + ')');
@ -33,20 +46,26 @@ export type AsyncFailable<T> = Promise<Failable<T>>;
export function HasFailed<T>(failable: Failable<T>): failable is Failure { export function HasFailed<T>(failable: Failable<T>): failable is Failure {
if (failable instanceof Promise) throw new Error('Invalid use of HasFailed'); 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<T>(failable: Failable<T>): failable is T { export function HasSuccess<T>(failable: Failable<T>): failable is T {
if (failable instanceof Promise) throw new Error('Invalid use of HasSuccess'); 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<T, U>(failable: Failable<T>, mapper: (value: T) => U): Failable<U> { export function Map<T, U>(
failable: Failable<T>,
mapper: (value: T) => U,
): Failable<U> {
if (HasFailed(failable)) return failable; if (HasFailed(failable)) return failable;
return mapper(failable); return mapper(failable);
} }
export function Open<T, U extends keyof T>(failable: Failable<T>, key: U): Failable<T[U]> { export function Open<T, U extends keyof T>(
failable: Failable<T>,
key: U,
): Failable<T[U]> {
if (HasFailed(failable)) return failable; if (HasFailed(failable)) return failable;
return failable[key]; return failable[key];
} }