diff --git a/backend/src/config/early/multipart.config.service.ts b/backend/src/config/early/multipart.config.service.ts index a31902d..12beb03 100644 --- a/backend/src/config/early/multipart.config.service.ts +++ b/backend/src/config/early/multipart.config.service.ts @@ -18,12 +18,12 @@ export class MultipartConfigService { ); } - public getLimits() { + public getLimits(fileLimit?: number) { return { fieldNameSize: 128, fieldSize: 1024, - fields: 16, - files: 16, + fields: 20, + files: fileLimit ?? 20, fileSize: this.getMaxFileSize(), }; } diff --git a/backend/src/decorators/decorators.module.ts b/backend/src/decorators/decorators.module.ts index 021bf37..d03d9a5 100644 --- a/backend/src/decorators/decorators.module.ts +++ b/backend/src/decorators/decorators.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; import { EarlyConfigModule } from '../config/early/early-config.module'; import { ImageIdPipe } from './image-id/image-id.pipe'; -import { MultiPartPipe } from './multipart/multipart.pipe'; import { PostFilePipe } from './multipart/postfile.pipe'; +import { MultiPartPipe } from './multipart/postfiles.pipe'; @Module({ imports: [EarlyConfigModule], diff --git a/backend/src/decorators/multipart/inject-request.decorator.ts b/backend/src/decorators/multipart/inject-request.decorator.ts index 591acfa..a9e467b 100644 --- a/backend/src/decorators/multipart/inject-request.decorator.ts +++ b/backend/src/decorators/multipart/inject-request.decorator.ts @@ -3,6 +3,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; // Since pipes dont have direct access to the request object, we need this decorator to inject it export const InjectRequest = createParamDecorator( async (data: any, ctx: ExecutionContext) => { - return ctx.switchToHttp().getRequest(); + return { + data: data, + request: ctx.switchToHttp().getRequest(), + }; }, ); diff --git a/backend/src/decorators/multipart/multipart.decorator.ts b/backend/src/decorators/multipart/multipart.decorator.ts index 91056a0..443fd9a 100644 --- a/backend/src/decorators/multipart/multipart.decorator.ts +++ b/backend/src/decorators/multipart/multipart.decorator.ts @@ -1,7 +1,7 @@ import { InjectRequest } from './inject-request.decorator'; -import { MultiPartPipe } from './multipart.pipe'; import { PostFilePipe } from './postfile.pipe'; +import { MultiPartPipe } from './postfiles.pipe'; export const PostFile = () => InjectRequest(PostFilePipe); -export const MultiPart = () => InjectRequest(MultiPartPipe); +export const PostFiles = (maxFiles?: number) => InjectRequest(maxFiles, MultiPartPipe); diff --git a/backend/src/decorators/multipart/multipart.pipe.ts b/backend/src/decorators/multipart/multipart.pipe.ts deleted file mode 100644 index 475c4ac..0000000 --- a/backend/src/decorators/multipart/multipart.pipe.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { MultipartFields, MultipartFile } from '@fastify/multipart'; -import { - ArgumentMetadata, - Injectable, - Logger, - PipeTransform, - Scope, -} from '@nestjs/common'; -import { FastifyRequest } from 'fastify'; -import { Fail, FT, HasFailed } from 'picsur-shared/dist/types'; -import { ZodDtoStatic } from 'picsur-shared/dist/util/create-zod-dto'; -import { MultipartConfigService } from '../../config/early/multipart.config.service'; -import { - CreateMultiPartFieldDto, - CreateMultiPartFileDto, -} from '../../models/dto/multipart.dto'; - -@Injectable({ scope: Scope.REQUEST }) -export class MultiPartPipe implements PipeTransform { - private readonly logger = new Logger(MultiPartPipe.name); - - constructor( - private readonly multipartConfigService: MultipartConfigService, - ) {} - - async transform( - req: FastifyRequest, - metadata: ArgumentMetadata, - ) { - let zodSchema = (metadata?.metatype as ZodDtoStatic)?.zodSchema; - if (!zodSchema) { - this.logger.error('Invalid scheme on multipart body'); - throw Fail(FT.Internal, 'Invalid scheme on backend'); - } - - let multipartData = {}; - if (!req.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file'); - - // Fetch all fields from the request - let fields: MultipartFields | null = null; - try { - fields = - ( - await req.file({ - limits: this.multipartConfigService.getLimits(), - }) - )?.fields ?? null; - } catch (e) { - this.logger.warn(e); - } - if (!fields) throw Fail(FT.UsrValidation, 'Invalid file'); - - // Loop over every formfield that was sent - for (const key of Object.keys(fields)) { - // Ignore duplicate fields - if (Array.isArray(fields[key])) { - continue; - } - - // Use the value property to differentiate between a field and a file - // And then put the value into the correct property on the validatable class - if ((fields[key] as any).value) { - (multipartData as any)[key] = CreateMultiPartFieldDto( - fields[key] as MultipartFile, - ); - } else { - const file = await CreateMultiPartFileDto(fields[key] as MultipartFile); - if (HasFailed(file)) throw file; - (multipartData as any)[key] = file; - } - } - - // Now validate the class we made, if any properties were invalid, it will error here - const result = zodSchema.safeParse(multipartData); - if (!result.success) { - this.logger.warn(result.error); - throw Fail(FT.UsrValidation, 'Invalid file'); - } - - return result.data; - } -} diff --git a/backend/src/decorators/multipart/postfile.pipe.ts b/backend/src/decorators/multipart/postfile.pipe.ts index 1a2afb8..8f44cc8 100644 --- a/backend/src/decorators/multipart/postfile.pipe.ts +++ b/backend/src/decorators/multipart/postfile.pipe.ts @@ -12,11 +12,11 @@ export class PostFilePipe implements PipeTransform { private readonly multipartConfigService: MultipartConfigService, ) {} - async transform({ req }: { req: FastifyRequest }) { - if (!req.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file'); + async transform({ request, data }: { data: any; request: FastifyRequest },) { + if (!request.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file'); // Only one file is allowed - const file = await req.file({ + const file = await request.file({ limits: { ...this.multipartConfigService.getLimits(), files: 1, diff --git a/backend/src/decorators/multipart/postfiles.pipe.ts b/backend/src/decorators/multipart/postfiles.pipe.ts new file mode 100644 index 0000000..3a3f18f --- /dev/null +++ b/backend/src/decorators/multipart/postfiles.pipe.ts @@ -0,0 +1,37 @@ +import { MultipartFile } from '@fastify/multipart'; +import { + ArgumentMetadata, + Injectable, + Logger, + PipeTransform, + Scope +} from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; +import { Fail, FT } from 'picsur-shared/dist/types'; +import { MultipartConfigService } from '../../config/early/multipart.config.service'; + +export type FileIterator = AsyncIterableIterator; + +@Injectable({ scope: Scope.REQUEST }) +export class MultiPartPipe implements PipeTransform { + private readonly logger = new Logger(MultiPartPipe.name); + + constructor( + private readonly multipartConfigService: MultipartConfigService, + ) {} + + async transform( + { request, data }: { data: any; request: FastifyRequest }, + metadata: ArgumentMetadata, + ) { + const filesLimit = typeof data === 'number' ? data : undefined; + + if (!request.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file'); + + const files = request.files({ + limits: this.multipartConfigService.getLimits(filesLimit), + }); + + return files; + } +} diff --git a/backend/src/models/dto/image-upload.dto.ts b/backend/src/models/dto/image-upload.dto.ts deleted file mode 100644 index b7a8016..0000000 --- a/backend/src/models/dto/image-upload.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createZodDto } from 'picsur-shared/dist/util/create-zod-dto'; -import { z } from 'zod'; -import { MultiPartFileDtoSchema } from './multipart.dto'; - -// A validation class for form based file upload of an image -export const ImageUploadDtoSchema = z.object({ - image: MultiPartFileDtoSchema, -}); -export class ImageUploadDto extends createZodDto(ImageUploadDtoSchema) {} diff --git a/backend/src/models/dto/multipart.dto.ts b/backend/src/models/dto/multipart.dto.ts deleted file mode 100644 index d2180ee..0000000 --- a/backend/src/models/dto/multipart.dto.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { MultipartFile } from '@fastify/multipart'; -import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types'; -import { z } from 'zod'; - -export const MultiPartFileDtoSchema = z.object({ - fieldname: z.string(), - encoding: z.string(), - filename: z.string(), - mimetype: z.string(), - buffer: z.any(), - file: z.any(), -}); -export type MultiPartFileDto = z.infer; - -export async function CreateMultiPartFileDto( - file: MultipartFile, -): AsyncFailable { - try { - const buffer = await file.toBuffer(); - return { - fieldname: file.fieldname, - encoding: file.encoding, - filename: file.filename, - mimetype: file.mimetype, - buffer, - file: file.file, - }; - } catch (e) { - return Fail(FT.Internal, e); - } -} - -export const MultiPartFieldDtoSchema = z.object({ - fieldname: z.string(), - encoding: z.string(), - value: z.string(), -}); -export type MultiPartFieldDto = z.infer; - -export function CreateMultiPartFieldDto( - file: MultipartFile, -): MultiPartFieldDto { - return { - fieldname: file.fieldname, - encoding: file.encoding, - value: (file as any).value, - }; -} diff --git a/backend/src/routes/image/image-manage.controller.ts b/backend/src/routes/image/image-manage.controller.ts index a0a18ca..be9422a 100644 --- a/backend/src/routes/image/image-manage.controller.ts +++ b/backend/src/routes/image/image-manage.controller.ts @@ -21,8 +21,9 @@ import { ImageUploadResponse } from 'picsur-shared/dist/dto/api/image-manage.dto'; import { Permission } from 'picsur-shared/dist/dto/permissions.enum'; -import { HasFailed, ThrowIfFailed } from 'picsur-shared/dist/types'; -import { MultiPart } from '../../decorators/multipart/multipart.decorator'; +import { Fail, FT, HasFailed, ThrowIfFailed } from 'picsur-shared/dist/types'; +import { PostFiles } from '../../decorators/multipart/multipart.decorator'; +import type { FileIterator } from '../../decorators/multipart/postfiles.pipe'; import { HasPermission, RequiredPermissions @@ -30,7 +31,7 @@ import { import { ReqUserID } from '../../decorators/request-user.decorator'; import { Returns } from '../../decorators/returns.decorator'; import { ImageManagerService } from '../../managers/image/image.service'; -import { ImageUploadDto } from '../../models/dto/image-upload.dto'; +import { GetNextAsync } from '../../util/iterator'; @Controller('api/image') @RequiredPermissions(Permission.ImageUpload) export class ImageManageController { @@ -42,15 +43,24 @@ export class ImageManageController { @Returns(ImageUploadResponse) @Throttle(20) async uploadImage( - @MultiPart() multipart: ImageUploadDto, + @PostFiles(1) multipart: FileIterator, @ReqUserID() userid: string, @HasPermission(Permission.ImageDeleteKey) withDeleteKey: boolean, ): Promise { + const file = ThrowIfFailed(await GetNextAsync(multipart)); + + let buffer: Buffer; + try { + buffer = await file.toBuffer(); + } catch (e) { + throw Fail(FT.Internal, e); + }; + const image = ThrowIfFailed( await this.imagesService.upload( userid, - multipart.image.filename, - multipart.image.buffer, + file.filename, + buffer, withDeleteKey, ), ); diff --git a/backend/src/routes/image/image.controller.ts b/backend/src/routes/image/image.controller.ts index 0e3bb9d..6335991 100644 --- a/backend/src/routes/image/image.controller.ts +++ b/backend/src/routes/image/image.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Head, Logger, Query, Res } from '@nestjs/common'; +import { SkipThrottle } from '@nestjs/throttler'; import type { FastifyReply } from 'fastify'; import { ImageMetaResponse, - ImageRequestParams, + ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto'; import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum'; import { FileType2Mime } from 'picsur-shared/dist/dto/mimes.dto'; @@ -21,6 +22,7 @@ import { BrandMessageType, GetBrandMessage } from '../../util/branding'; // This is the only controller with CORS enabled @Controller('i') @RequiredPermissions(Permission.ImageView) +@SkipThrottle() export class ImageController { private readonly logger = new Logger(ImageController.name); diff --git a/backend/src/util/iterator.ts b/backend/src/util/iterator.ts new file mode 100644 index 0000000..58502cd --- /dev/null +++ b/backend/src/util/iterator.ts @@ -0,0 +1,9 @@ +import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types'; + +export async function GetNextAsync( + iterator: AsyncIterableIterator, +): AsyncFailable { + const { done, value } = await iterator.next(); + if (done) return Fail(FT.BadRequest); + return value; +} diff --git a/backend/src/workers/sharp/universal-sharp.ts b/backend/src/workers/sharp/universal-sharp.ts index ba01820..5aad601 100644 --- a/backend/src/workers/sharp/universal-sharp.ts +++ b/backend/src/workers/sharp/universal-sharp.ts @@ -2,7 +2,7 @@ import { BMPdecode, BMPencode } from 'bmp-img'; import { AnimFileType, FileType, - ImageFileType, + ImageFileType } from 'picsur-shared/dist/dto/mimes.dto'; import { QOIdecode, QOIencode } from 'qoi-img'; import sharp, { Sharp, SharpOptions } from 'sharp'; diff --git a/frontend/src/app/models/dto/processing-view-meta.dto.ts b/frontend/src/app/models/dto/processing-view-meta.dto.ts index 6a402fa..681ea1d 100644 --- a/frontend/src/app/models/dto/processing-view-meta.dto.ts +++ b/frontend/src/app/models/dto/processing-view-meta.dto.ts @@ -1,3 +1,9 @@ -export interface ProcessingViewMeta { - imageFile: File; +export class ProcessingViewMeta { + private _tag = 'ProcessingViewMeta'; + + constructor(public imageFiles: File[]) {} + + static is(value: any): value is ProcessingViewMeta { + return (value ?? {})._tag === 'ProcessingViewMeta'; + } } diff --git a/frontend/src/app/routes/processing/processing.component.ts b/frontend/src/app/routes/processing/processing.component.ts index 0f6e3c2..114ec27 100644 --- a/frontend/src/app/routes/processing/processing.component.ts +++ b/frontend/src/app/routes/processing/processing.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Fail, FT, HasFailed } from 'picsur-shared/dist/types'; import { ProcessingViewMeta } from 'src/app/models/dto/processing-view-meta.dto'; +import { ApiService } from 'src/app/services/api/api.service'; import { ImageService } from 'src/app/services/api/image.service'; import { Logger } from 'src/app/services/logger/logger.service'; import { ErrorService } from 'src/app/util/error-manager/error.service'; @@ -16,11 +17,12 @@ export class ProcessingComponent implements OnInit { private readonly router: Router, private readonly imageService: ImageService, private readonly errorService: ErrorService, + private readonly apiService: ApiService, ) {} async ngOnInit() { const state = history.state as ProcessingViewMeta; - if (!state) { + if (!ProcessingViewMeta.is(state)) { return this.errorService.quitFailure( Fail(FT.UsrValidation, 'No state provided'), this.logger, @@ -29,7 +31,8 @@ export class ProcessingComponent implements OnInit { history.replaceState(null, ''); - const id = await this.imageService.UploadImage(state.imageFile); + const id = await this.imageService.UploadImage(state.imageFiles[0]); + if (HasFailed(id)) return this.errorService.quitFailure(id, this.logger); this.router.navigate([`/view/`, id], { replaceUrl: true }); diff --git a/frontend/src/app/routes/upload/upload.component.ts b/frontend/src/app/routes/upload/upload.component.ts index 3b1e393..4c29952 100644 --- a/frontend/src/app/routes/upload/upload.component.ts +++ b/frontend/src/app/routes/upload/upload.component.ts @@ -39,14 +39,9 @@ export class UploadComponent implements OnInit { } onSelect(event: NgxDropzoneChangeEvent) { - if (event.addedFiles.length > 1) - this.errorService.log( - 'You uploaded multiple images, only one has been uploaded', - ); - - const metadata: ProcessingViewMeta = { - imageFile: event.addedFiles[0], - }; + const metadata: ProcessingViewMeta = new ProcessingViewMeta( + event.addedFiles, + ); this.router.navigate(['/processing'], { state: metadata }); } @@ -64,21 +59,16 @@ export class UploadComponent implements OnInit { 'Your clipboard does not contain any images', ); - const blob = filteredItems[0].getAsFile(); - if (!blob) + const blobs = filteredItems.map((item) => item.getAsFile()); + if (blobs.some((blob) => blob === null)) return this.errorService.showFailure( Fail(FT.Internal, 'Error getting image from clipboard'), this.logger, ); - if (filteredItems.length > 1) - this.errorService.log( - 'You pasted multiple images, only one has been uploaded', - ); + const safeBlob = blobs as File[]; - const metadata: ProcessingViewMeta = { - imageFile: blob, - }; + const metadata: ProcessingViewMeta = new ProcessingViewMeta(safeBlob); this.router.navigate(['/processing'], { state: metadata }); } diff --git a/shared/src/types/failable.ts b/shared/src/types/failable.ts index a7d3ad0..1260876 100644 --- a/shared/src/types/failable.ts +++ b/shared/src/types/failable.ts @@ -9,6 +9,7 @@ export enum FT { Database = 'database', SysValidation = 'sysvalidation', UsrValidation = 'usrvalidation', + BadRequest = 'badrequest', Permission = 'permission', RateLimit = 'ratelimit', NotFound = 'notfound', @@ -59,6 +60,11 @@ const FTProps: { code: 400, message: 'Validation of user input failed', }, + [FT.BadRequest]: { + important: false, + code: 400, + message: 'Bad request', + }, [FT.Permission]: { important: false, code: 403,