Change upload mechanisms
This commit is contained in:
parent
f6f94a9e01
commit
9845fae599
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue