Change upload mechanisms

This commit is contained in:
Rubikscraft 2022-12-26 12:46:33 +01:00
parent f6f94a9e01
commit 9845fae599
17 changed files with 105 additions and 178 deletions

View File

@ -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(),
};
}

View File

@ -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],

View File

@ -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(),
};
},
);

View File

@ -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);

View File

@ -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<T extends Object>(
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;
}
}

View File

@ -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,

View File

@ -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<MultipartFile>;
@Injectable({ scope: Scope.REQUEST })
export class MultiPartPipe implements PipeTransform {
private readonly logger = new Logger(MultiPartPipe.name);
constructor(
private readonly multipartConfigService: MultipartConfigService,
) {}
async transform<T extends Object>(
{ 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;
}
}

View File

@ -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) {}

View File

@ -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<typeof MultiPartFileDtoSchema>;
export async function CreateMultiPartFileDto(
file: MultipartFile,
): AsyncFailable<MultiPartFileDto> {
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<typeof MultiPartFieldDtoSchema>;
export function CreateMultiPartFieldDto(
file: MultipartFile,
): MultiPartFieldDto {
return {
fieldname: file.fieldname,
encoding: file.encoding,
value: (file as any).value,
};
}

View File

@ -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<ImageUploadResponse> {
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,
),
);

View File

@ -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);

View File

@ -0,0 +1,9 @@
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
export async function GetNextAsync<T>(
iterator: AsyncIterableIterator<T>,
): AsyncFailable<T> {
const { done, value } = await iterator.next();
if (done) return Fail(FT.BadRequest);
return value;
}

View File

@ -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';

View File

@ -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';
}
}

View File

@ -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 });

View File

@ -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 });
}

View File

@ -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,