make image converting a job too

This commit is contained in:
rubikscraft
2022-09-19 16:53:58 +02:00
parent e622972e08
commit 38c2b9d42e
27 changed files with 402 additions and 665 deletions

View File

@@ -12,7 +12,6 @@ import { PicsurLayersModule } from './layers/PicsurLayers.module';
import { PicsurLoggerModule } from './logger/logger.module';
import { AuthManagerModule } from './managers/auth/auth.module';
import { DemoManagerModule } from './managers/demo/demo.module';
import { IngestManagerModule } from './managers/ingest/ingest.module';
import { UsageManagerModule } from './managers/usage/usage.module';
import { PicsurRoutesModule } from './routes/routes.module';
@@ -59,7 +58,6 @@ const imageCorsOverride = (
DemoManagerModule,
PicsurRoutesModule,
PicsurLayersModule,
IngestManagerModule,
],
})
export class AppModule implements NestModule {

View File

@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EIngestFileBackend } from '../../database/entities/ingest-file.entity';
import { IngestFileDbService } from './ingest-file-db.service';
@Module({
imports: [TypeOrmModule.forFeature([EIngestFileBackend])],
providers: [IngestFileDbService],
exports: [IngestFileDbService],
})
export class IngestFileDbModule {}

View File

@@ -1,32 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
import { Repository } from 'typeorm';
import { EIngestFileBackend } from '../../database/entities/ingest-file.entity';
@Injectable()
export class IngestFileDbService {
private readonly logger = new Logger(IngestFileDbService.name);
constructor(
@InjectRepository(EIngestFileBackend)
private readonly ingressFileRepo: Repository<EIngestFileBackend>,
) {}
public async uploadFile(
filename: string,
file: Buffer,
): AsyncFailable<string> {
const ingressFile = new EIngestFileBackend();
ingressFile.filename = filename;
ingressFile.data = file;
try {
await this.ingressFileRepo.save(ingressFile);
} catch (e) {
return Fail(FT.Database, e);
}
return ingressFile.id;
}
}

View File

@@ -42,7 +42,6 @@ export class PreferenceDefaultsService {
[SysPreference.BCryptStrength]: 10,
[SysPreference.RemoveDerivativesAfter]: '7d',
[SysPreference.SaveDerivatives]: true,
[SysPreference.AllowEditing]: true,
[SysPreference.ConversionTimeLimit]: '15s',

View File

@@ -2,7 +2,6 @@ import { EApiKeyBackend } from './apikey.entity';
import { EImageDerivativeBackend } from './images/image-derivative.entity';
import { EImageFileBackend } from './images/image-file.entity';
import { EImageBackend } from './images/image.entity';
import { EIngestFileBackend } from './ingest-file.entity';
import { ESysPreferenceBackend } from './system/sys-preference.entity';
import { ESystemStateBackend } from './system/system-state.entity';
import { EUsrPreferenceBackend } from './system/usr-preference.entity';
@@ -19,5 +18,4 @@ export const EntityList = [
EUsrPreferenceBackend,
EApiKeyBackend,
ESystemStateBackend,
EIngestFileBackend,
];

View File

@@ -1,16 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class EIngestFileBackend {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
filename: string;
@Column({ type: 'bytea', nullable: false })
data: Buffer;
@Column({ nullable: false, default: false })
in_use: boolean;
}

View File

@@ -4,7 +4,7 @@ import {
Injectable,
Logger,
NestInterceptor,
Optional,
Optional
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { FastifyReply } from 'fastify';
@@ -46,6 +46,20 @@ export class SuccessInterceptor<T> implements NestInterceptor {
return data;
}
}),
map((data) => {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse<FastifyReply>();
const traceString = `(${request.ip} -> ${request.method} ${request.url})`;
this.logger.verbose(
`Handled ${traceString} with ${response.statusCode} in ${Math.ceil(
response.getResponseTime(),
)}ms`,
SuccessInterceptor.name,
);
return data;
}),
);
}

View File

@@ -44,12 +44,9 @@ async function bootstrap() {
);
// Configure logger
const logger = app.get(PicsurLoggerService)
app.useLogger(logger);
app.useLogger(app.get(PicsurLoggerService));
app.flushLogs();
console.log(logger);
app.useGlobalFilters(app.get(MainExceptionFilter));
app.useGlobalInterceptors(app.get(SuccessInterceptor));
app.useGlobalPipes(app.get(ZodValidationPipe));

View File

@@ -0,0 +1,94 @@
import { OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import type { Job } from 'bull';
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { IsFailure, ThrowIfFailed } from 'picsur-shared/dist/types';
import { ParseFileType } from 'picsur-shared/dist/util/parse-mime';
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { ImageConverterService } from './image-converter.service';
import { ImageManagerService } from './image-manager.service';
import { ImageQueueID, ImageQueueSubject } from './image.queue';
// This contains the job to convert an image to a derivative and store it
export interface ImageConvertJobData {
uniqueKey: string;
imageId: string;
fileType: string;
options: ImageRequestParams;
}
export type ImageConvertJob = Job<ImageConvertJobData>;
@Processor(ImageQueueID)
export class ConvertConsumer {
private readonly logger = new Logger(ConvertConsumer.name);
constructor(
private readonly imageFilesService: ImageFileDBService,
private readonly imageConverter: ImageConverterService,
private readonly sysPref: SysPreferenceDbService,
private readonly imageService: ImageManagerService,
) {}
@Process(ImageQueueSubject.CONVERT)
async convertImage(job: ImageConvertJob): Promise<void> {
const { imageId, fileType, options, uniqueKey } = job.data;
// Get file type
const targetFileType = ThrowIfFailed(ParseFileType(fileType));
// Get preferences
const allow_editing = ThrowIfFailed(
await this.sysPref.getBooleanPreference(SysPreference.AllowEditing),
);
// Get master image
const masterImage = ThrowIfFailed(
await this.imageService.getMaster(imageId),
);
const sourceFileType = ThrowIfFailed(ParseFileType(masterImage.filetype));
// Conver timage
const startTime = Date.now();
const convertResult = ThrowIfFailed(
await this.imageConverter.convert(
masterImage.data,
sourceFileType,
targetFileType,
allow_editing ? options : {},
),
);
this.logger.verbose(
`Converted ${imageId} from ${sourceFileType.identifier} to ${
targetFileType.identifier
} in ${Date.now() - startTime}ms`,
);
ThrowIfFailed(
await this.imageFilesService.addDerivative(
imageId,
uniqueKey,
convertResult.filetype,
convertResult.image,
),
);
}
@OnQueueError()
async handleError(error: any) {
if (IsFailure(error)) error.print(this.logger);
else this.logger.error(error);
}
@OnQueueFailed()
async handleFailed(job: Job, error: any) {
if (IsFailure(error))
error.print(this.logger, {
prefix: `[JOB ${job.id}]`,
});
else this.logger.error(error);
}
}

View File

@@ -0,0 +1,97 @@
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import Crypto from 'crypto';
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
ThrowIfFailed
} from 'picsur-shared/dist/types';
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity';
import { ImageConvertJob } from './convert.consumer';
import * as ImageQueue from './image.queue';
@Injectable()
export class ConvertService {
constructor(
@InjectQueue(ImageQueue.ImageQueueID)
private readonly imageQueue: ImageQueue.ImageQueueType,
private readonly imageFilesService: ImageFileDBService,
) {}
public async convertJob(
imageId: string,
fileType: string,
options: ImageRequestParams,
): AsyncFailable<ImageConvertJob> {
const jobID = this.getConvertHash(imageId, { fileType, ...options });
/*
Jobs with the same ID don't get executed, we abuse this by passing it a hash of the input parameters.
This way, if the same image is requested with the same parameters, we don't have to convert it again.
Since it will always produce the same output with the same inputs
*/
let job: ImageConvertJob;
try {
job = (await this.imageQueue.add(
ImageQueue.ImageQueueSubject.CONVERT,
{
imageId,
fileType,
options,
uniqueKey: jobID,
},
{
jobId: jobID,
},
)) as ImageConvertJob;
} catch (e) {
return Fail(FT.Internal, e);
}
if (!job.id) return Fail(FT.Internal, undefined, 'Failed to queue job');
return job;
}
public async convertPromise(
imageId: string,
fileType: string,
options: ImageRequestParams,
): AsyncFailable<EImageDerivativeBackend> {
const uniqueKey = this.getConvertHash(imageId, { fileType, ...options });
const findExisting = ThrowIfFailed(
await this.imageFilesService.getDerivative(imageId, uniqueKey),
);
if (findExisting !== null) return findExisting;
const job = await this.convertJob(imageId, fileType, options);
if (HasFailed(job)) return job;
try {
await job.finished();
} catch (e) {
return Fail(FT.Internal, 'Failed to convert image', e);
}
const findResult = ThrowIfFailed(
await this.imageFilesService.getDerivative(imageId, uniqueKey),
);
if (findResult !== null) return findResult;
return Fail(FT.Internal, 'Failed to convert image');
}
private getConvertHash(imageID: string, options: object) {
// Return a sha256 hash of the stringified options
const stringified = JSON.stringify(options) + '-' + imageID;
const hash = Crypto.createHash('sha256');
hash.update(stringified);
const digest = hash.digest('hex');
return digest;
}
}

View File

@@ -1,3 +1,4 @@
import { BullModule } from '@nestjs/bull';
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import ms from 'ms';
@@ -8,18 +9,36 @@ import { ImageDBService } from '../../collections/image-db/image-db.service';
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
import { PreferenceDbModule } from '../../collections/preference-db/preference-db.module';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { ConvertConsumer } from './convert.consumer';
import { ConvertService } from './convert.service';
import { ImageConverterService } from './image-converter.service';
import { ImageProcessorService } from './image-processor.service';
import { ImageManagerService } from './image.service';
import { ImageManagerService } from './image-manager.service';
import { ImageQueueID } from './image.queue';
import { IngestConsumer } from './ingest.consumer';
import { IngestService } from './ingest.service';
@Module({
imports: [ImageDBModule, PreferenceDbModule],
imports: [
ImageDBModule,
PreferenceDbModule,
BullModule.registerQueue({
name: ImageQueueID,
}),
],
providers: [
ImageManagerService,
ImageProcessorService,
ImageConverterService,
IngestConsumer,
ConvertConsumer,
IngestService,
ConvertService,
],
exports: [
ImageManagerService,
ImageConverterService,
IngestService,
ConvertService,
],
exports: [ImageManagerService, ImageConverterService],
})
export class ImageManagerModule implements OnModuleInit {
private readonly logger = new Logger(ImageManagerModule.name);
@@ -49,11 +68,12 @@ export class ImageManagerModule implements OnModuleInit {
return;
}
const after_ms = ms(remove_derivatives_after as any);
let after_ms = ms(remove_derivatives_after as any);
if (isNaN(after_ms) || after_ms === 0) {
this.logger.log('remove_derivatives_after is 0, skipping cron');
return;
}
if (after_ms < 60000) after_ms = 60000;
const result = await this.imageFileDB.cleanupDerivatives(after_ms / 1000);
if (HasFailed(result)) {

View File

@@ -0,0 +1,106 @@
import { Injectable, Logger } from '@nestjs/common';
import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum';
import { FileType } from 'picsur-shared/dist/dto/mimes.dto';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { ParseFileType } from 'picsur-shared/dist/util/parse-mime';
import { ImageDBService } from '../../collections/image-db/image-db.service';
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
import { EImageFileBackend } from '../../database/entities/images/image-file.entity';
import { EImageBackend } from '../../database/entities/images/image.entity';
@Injectable()
export class ImageManagerService {
private readonly logger = new Logger(ImageManagerService.name);
constructor(
private readonly imagesService: ImageDBService,
private readonly imageFilesService: ImageFileDBService,
) {}
public async findOne(id: string): AsyncFailable<EImageBackend> {
return await this.imagesService.findOne(id, undefined);
}
public async findMany(
count: number,
page: number,
userid: string | undefined,
): AsyncFailable<FindResult<EImageBackend>> {
return await this.imagesService.findMany(count, page, userid);
}
public async update(
id: string,
userid: string | undefined,
options: Partial<Pick<EImageBackend, 'file_name' | 'expires_at'>>,
): AsyncFailable<EImageBackend> {
if (options.expires_at !== undefined && options.expires_at !== null) {
if (options.expires_at < new Date()) {
return Fail(FT.UsrValidation, 'Expiration date must be in the future');
}
}
return await this.imagesService.update(id, userid, options);
}
public async deleteMany(
ids: string[],
userid: string | undefined,
): AsyncFailable<EImageBackend[]> {
return await this.imagesService.delete(ids, userid);
}
public async deleteWithKey(
imageId: string,
key: string,
): AsyncFailable<EImageBackend> {
return await this.imagesService.deleteWithKey(imageId, key);
}
// File getters ==============================================================
public async getMaster(imageId: string): AsyncFailable<EImageFileBackend> {
return this.imageFilesService.getFile(imageId, ImageEntryVariant.MASTER);
}
public async getMasterFileType(imageId: string): AsyncFailable<FileType> {
const mime = await this.imageFilesService.getFileTypes(imageId);
if (HasFailed(mime)) return mime;
if (mime['master'] === undefined)
return Fail(FT.NotFound, 'No master file');
return ParseFileType(mime['master']);
}
public async getOriginal(imageId: string): AsyncFailable<EImageFileBackend> {
return this.imageFilesService.getFile(imageId, ImageEntryVariant.ORIGINAL);
}
public async getOriginalFileType(imageId: string): AsyncFailable<FileType> {
const filetypes = await this.imageFilesService.getFileTypes(imageId);
if (HasFailed(filetypes)) return filetypes;
if (filetypes['original'] === undefined)
return Fail(FT.NotFound, 'No original file');
return ParseFileType(filetypes['original']);
}
public async getFileMimes(imageId: string): AsyncFailable<{
[ImageEntryVariant.MASTER]: string;
[ImageEntryVariant.ORIGINAL]: string | undefined;
}> {
const result = await this.imageFilesService.getFileTypes(imageId);
if (HasFailed(result)) return result;
if (result[ImageEntryVariant.MASTER] === undefined) {
return Fail(FT.NotFound, 'No master file found');
}
return {
[ImageEntryVariant.MASTER]: result[ImageEntryVariant.MASTER]!,
[ImageEntryVariant.ORIGINAL]: result[ImageEntryVariant.ORIGINAL],
};
}
}

View File

@@ -1,50 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
FileType,
ImageFileType,
SupportedFileTypeCategory
} from 'picsur-shared/dist/dto/mimes.dto';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { ParseFileType } from 'picsur-shared/dist/util/parse-mime';
import { ImageConverterService } from './image-converter.service';
import { ImageResult } from './imageresult';
@Injectable()
export class ImageProcessorService {
constructor(private readonly imageConverter: ImageConverterService) {}
public async process(
image: Buffer,
filetype: FileType,
): AsyncFailable<ImageResult> {
if (filetype.category === SupportedFileTypeCategory.Image) {
return await this.processStill(image, filetype);
} else if (filetype.category === SupportedFileTypeCategory.Animation) {
return await this.processAnimation(image, filetype);
} else {
return Fail(FT.SysValidation, 'Unsupported mime type');
}
}
private async processStill(
image: Buffer,
filetype: FileType,
): AsyncFailable<ImageResult> {
const outputFileType = ParseFileType(ImageFileType.QOI);
if (HasFailed(outputFileType)) return outputFileType;
return this.imageConverter.convert(image, filetype, outputFileType, {});
}
private async processAnimation(
image: Buffer,
filetype: FileType,
): AsyncFailable<ImageResult> {
// Webps and gifs are stored as is for now
return {
image: image,
filetype: filetype.identifier,
};
}
}

View File

@@ -0,0 +1,12 @@
import { Queue } from 'bull';
import { ImageConvertJobData } from './convert.consumer';
import { ImageIngestJobData } from './ingest.consumer';
export const ImageQueueID = 'image-queue';
export type ImageQueueType = Queue<ImageIngestJobData | ImageConvertJobData>;
export enum ImageQueueSubject {
INGEST = 'ingest',
CONVERT = 'convert',
}

View File

@@ -1,288 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import Crypto from 'crypto';
import { fileTypeFromBuffer, FileTypeResult } from 'file-type';
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum';
import {
AnimFileType,
FileType, ImageFileType,
Mime2FileType
} from 'picsur-shared/dist/dto/mimes.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.enum';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { ParseFileType } from 'picsur-shared/dist/util/parse-mime';
import { IsQOI } from 'qoi-img';
import { ImageDBService } from '../../collections/image-db/image-db.service';
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { UsrPreferenceDbService } from '../../collections/preference-db/usr-preference-db.service';
import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/images/image-file.entity';
import { EImageBackend } from '../../database/entities/images/image.entity';
import { MutexFallBack } from '../../util/mutex-fallback';
import { ImageConverterService } from './image-converter.service';
import { ImageProcessorService } from './image-processor.service';
import { WebPInfo } from './webpinfo/webpinfo';
@Injectable()
export class ImageManagerService {
private readonly logger = new Logger(ImageManagerService.name);
constructor(
private readonly imagesService: ImageDBService,
private readonly imageFilesService: ImageFileDBService,
private readonly processService: ImageProcessorService,
private readonly convertService: ImageConverterService,
private readonly userPref: UsrPreferenceDbService,
private readonly sysPref: SysPreferenceDbService,
) {}
public async findOne(id: string): AsyncFailable<EImageBackend> {
return await this.imagesService.findOne(id, undefined);
}
public async findMany(
count: number,
page: number,
userid: string | undefined,
): AsyncFailable<FindResult<EImageBackend>> {
return await this.imagesService.findMany(count, page, userid);
}
public async update(
id: string,
userid: string | undefined,
options: Partial<Pick<EImageBackend, 'file_name' | 'expires_at'>>,
): AsyncFailable<EImageBackend> {
if (options.expires_at !== undefined && options.expires_at !== null) {
if (options.expires_at < new Date()) {
return Fail(FT.UsrValidation, 'Expiration date must be in the future');
}
}
return await this.imagesService.update(id, userid, options);
}
public async deleteMany(
ids: string[],
userid: string | undefined,
): AsyncFailable<EImageBackend[]> {
return await this.imagesService.delete(ids, userid);
}
public async deleteWithKey(
imageId: string,
key: string,
): AsyncFailable<EImageBackend> {
return await this.imagesService.deleteWithKey(imageId, key);
}
public async upload(
userid: string,
filename: string,
image: Buffer,
withDeleteKey: boolean,
): AsyncFailable<EImageBackend> {
const fileType = await this.getFileTypeFromBuffer(image);
if (HasFailed(fileType)) return fileType;
// Check if need to save orignal
const keepOriginal = await this.userPref.getBooleanPreference(
userid,
UsrPreference.KeepOriginal,
);
if (HasFailed(keepOriginal)) return keepOriginal;
// Process
const processResult = await this.processService.process(image, fileType);
if (HasFailed(processResult)) return processResult;
// Strip extension from filename
const name = (() => {
const index = filename.lastIndexOf('.');
if (index === -1) return filename;
return filename.substring(0, index);
})();
// Save processed to db
const imageEntity = await this.imagesService.create(
userid,
name,
withDeleteKey,
);
if (HasFailed(imageEntity)) return imageEntity;
const imageFileEntity = await this.imageFilesService.setFile(
imageEntity.id,
ImageEntryVariant.MASTER,
processResult.image,
processResult.filetype,
);
if (HasFailed(imageFileEntity)) return imageFileEntity;
if (keepOriginal) {
const originalFileEntity = await this.imageFilesService.setFile(
imageEntity.id,
ImageEntryVariant.ORIGINAL,
image,
fileType.identifier,
);
if (HasFailed(originalFileEntity)) return originalFileEntity;
}
return imageEntity;
}
public async getConverted(
imageId: string,
fileType: string,
options: ImageRequestParams,
): AsyncFailable<EImageDerivativeBackend> {
const targetFileType = ParseFileType(fileType);
if (HasFailed(targetFileType)) return targetFileType;
const converted_key = this.getConvertHash({ mime: fileType, ...options });
const [save_derivatives, allow_editing] = await Promise.all([
this.sysPref.getBooleanPreference(SysPreference.SaveDerivatives),
this.sysPref.getBooleanPreference(SysPreference.AllowEditing),
]);
if (HasFailed(save_derivatives)) return save_derivatives;
if (HasFailed(allow_editing)) return allow_editing;
return MutexFallBack(
converted_key,
() => {
if (save_derivatives)
return this.imageFilesService.getDerivative(imageId, converted_key);
else return Promise.resolve(null);
},
async () => {
const masterImage = await this.getMaster(imageId);
if (HasFailed(masterImage)) return masterImage;
const sourceFileType = ParseFileType(masterImage.filetype);
if (HasFailed(sourceFileType)) return sourceFileType;
const startTime = Date.now();
const convertResult = await this.convertService.convert(
masterImage.data,
sourceFileType,
targetFileType,
allow_editing ? options : {},
);
if (HasFailed(convertResult)) return convertResult;
this.logger.verbose(
`Converted ${imageId} from ${sourceFileType.identifier} to ${
targetFileType.identifier
} in ${Date.now() - startTime}ms`,
);
if (save_derivatives) {
return await this.imageFilesService.addDerivative(
imageId,
converted_key,
convertResult.filetype,
convertResult.image,
);
} else {
const derivative = new EImageDerivativeBackend();
derivative.filetype = convertResult.filetype;
derivative.data = convertResult.image;
derivative.image_id = imageId;
derivative.key = converted_key;
return derivative;
}
},
);
}
// File getters ==============================================================
public async getMaster(imageId: string): AsyncFailable<EImageFileBackend> {
return this.imageFilesService.getFile(imageId, ImageEntryVariant.MASTER);
}
public async getMasterFileType(imageId: string): AsyncFailable<FileType> {
const mime = await this.imageFilesService.getFileTypes(imageId);
if (HasFailed(mime)) return mime;
if (mime['master'] === undefined)
return Fail(FT.NotFound, 'No master file');
return ParseFileType(mime['master']);
}
public async getOriginal(imageId: string): AsyncFailable<EImageFileBackend> {
return this.imageFilesService.getFile(imageId, ImageEntryVariant.ORIGINAL);
}
public async getOriginalFileType(imageId: string): AsyncFailable<FileType> {
const filetypes = await this.imageFilesService.getFileTypes(imageId);
if (HasFailed(filetypes)) return filetypes;
if (filetypes['original'] === undefined)
return Fail(FT.NotFound, 'No original file');
return ParseFileType(filetypes['original']);
}
public async getFileMimes(imageId: string): AsyncFailable<{
[ImageEntryVariant.MASTER]: string;
[ImageEntryVariant.ORIGINAL]: string | undefined;
}> {
const result = await this.imageFilesService.getFileTypes(imageId);
if (HasFailed(result)) return result;
if (result[ImageEntryVariant.MASTER] === undefined) {
return Fail(FT.NotFound, 'No master file found');
}
return {
[ImageEntryVariant.MASTER]: result[ImageEntryVariant.MASTER]!,
[ImageEntryVariant.ORIGINAL]: result[ImageEntryVariant.ORIGINAL],
};
}
// Util stuff ==================================================================
private async getFileTypeFromBuffer(image: Buffer): AsyncFailable<FileType> {
const filetypeResult: FileTypeResult | undefined = await fileTypeFromBuffer(
image,
);
let mime: string | undefined;
if (filetypeResult === undefined) {
if (IsQOI(image)) mime = 'image/x-qoi';
} else {
mime = filetypeResult.mime;
}
if (mime === undefined) mime = 'other/unknown';
let filetype: string | undefined;
if (mime === 'image/webp') {
const header = await WebPInfo.from(image);
if (header.summary.isAnimated) filetype = AnimFileType.WEBP;
else filetype = ImageFileType.WEBP;
}
if (filetype === undefined) {
const parsed = Mime2FileType(mime);
if (HasFailed(parsed)) return parsed;
filetype = parsed;
}
return ParseFileType(filetype);
}
private getConvertHash(options: object) {
// Return a sha256 hash of the stringified options
const stringified = JSON.stringify(options);
const hash = Crypto.createHash('sha256');
hash.update(stringified);
const digest = hash.digest('hex');
return digest;
}
}

View File

@@ -1,12 +1,6 @@
import {
InjectQueue,
OnQueueError,
OnQueueFailed,
Process,
Processor
} from '@nestjs/bull';
import { OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import type { Job, Queue } from 'bull';
import type { Job } from 'bull';
import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum';
import {
FileType,
@@ -27,47 +21,28 @@ import { ImageFileDBService } from '../../collections/image-db/image-file-db.ser
import { EImageBackend } from '../../database/entities/images/image.entity';
import { ImageConverterService } from '../image/image-converter.service';
import { ImageResult } from '../image/imageresult';
import { ImageQueueID, ImageQueueSubject } from './image.queue';
interface ImageIngestJobData {
export interface ImageIngestJobData {
imageID: string;
storeOriginal: boolean;
}
export type ImageIngestQueue = Queue<ImageIngestJobData>;
export type ImageIngestJob = Job<ImageIngestJobData>;
@Processor('image-ingest')
@Processor(ImageQueueID)
export class IngestConsumer {
private readonly logger = new Logger(IngestConsumer.name);
private i = 0;
constructor(
@InjectQueue('image-ingest') private readonly ingestQueue: Queue,
private readonly imagesService: ImageDBService,
private readonly imageFilesService: ImageFileDBService,
private readonly imageConverter: ImageConverterService,
) {
this.logger.log('Ingest consumer started');
this.logger.error('Ingest consumer started');
}
) {}
// @Process('group')
// async processJob(job: Job<GroupIngestJob>) {
// console.log('Received', job.data);
// await new Promise((resolve) => setTimeout(resolve, 4000));
// console.log('Done');
// return 'big chungus';
// }
@Process('image')
async processImage(job: ImageIngestJob): Promise<EImageBackend> {
@Process(ImageQueueSubject.INGEST)
async ingestImage(job: ImageIngestJob): Promise<EImageBackend> {
const { imageID, storeOriginal } = job.data;
job.failedReason = 'Not implemented';
if (this.i === 0) {
throw Fail(FT.Internal, undefined, 'oops');
}
// Already start the query for the image, we only need it when returning
const imagePromise = this.imagesService.findOne(imageID, undefined);
@@ -110,7 +85,7 @@ export class IngestConsumer {
return image;
}
public async process(
private async process(
image: Buffer,
filetype: FileType,
): AsyncFailable<ImageResult> {

View File

@@ -17,14 +17,16 @@ import { ImageFileDBService } from '../../collections/image-db/image-file-db.ser
import { UsrPreferenceDbService } from '../../collections/preference-db/usr-preference-db.service';
import { EImageBackend } from '../../database/entities/images/image.entity';
import { WebPInfo } from '../image/webpinfo/webpinfo';
import type { ImageIngestJob, ImageIngestQueue } from './ingest.consumer';
import * as ImageQueue from './image.queue';
import { ImageIngestJob } from './ingest.consumer';
@Injectable()
export class IngestService {
private readonly logger = new Logger(IngestService.name);
constructor(
@InjectQueue('image-ingest') private readonly ingestQueue: ImageIngestQueue,
@InjectQueue(ImageQueue.ImageQueueID)
private readonly imageQueue: ImageQueue.ImageQueueType,
private readonly imagesService: ImageDBService,
private readonly imageFilesService: ImageFileDBService,
private readonly userPref: UsrPreferenceDbService,
@@ -35,7 +37,7 @@ export class IngestService {
filename: string,
image: Buffer,
withDeleteKey: boolean,
): AsyncFailable<ImageIngestJob> {
): AsyncFailable<[ImageIngestJob, EImageBackend]> {
const fileType = await this.getFileTypeFromBuffer(image);
if (HasFailed(fileType)) return fileType;
@@ -69,13 +71,20 @@ export class IngestService {
);
if (HasFailed(imageFileEntity)) return imageFileEntity;
const job = await this.ingestQueue.add('image', {
imageID: imageEntity.id,
storeOriginal: keepOriginal,
});
if (!job.id) return Fail(FT.Internal, undefined, 'Failed to queue job');
try {
const job = (await this.imageQueue.add(
ImageQueue.ImageQueueSubject.INGEST,
{
imageID: imageEntity.id,
storeOriginal: keepOriginal,
},
)) as ImageIngestJob;
if (!job.id) return Fail(FT.Internal, undefined, 'Failed to queue job');
return job;
return [job, imageEntity];
} catch (e) {
return Fail(FT.Internal, e);
}
}
public async uploadPromise(
@@ -84,11 +93,13 @@ export class IngestService {
image: Buffer,
withDeleteKey: boolean,
): AsyncFailable<EImageBackend> {
const job = await this.uploadJob(userid, filename, image, withDeleteKey);
if (HasFailed(job)) return job;
const result = await this.uploadJob(userid, filename, image, withDeleteKey);
if (HasFailed(result)) return result;
const [job, imageEntity] = result;
try {
const imageEntity: EImageBackend = await job.finished();
await job.finished();
return imageEntity;
} catch (e) {
return Fail(FT.Internal, 'Failed to process image', e);

View File

@@ -1,21 +0,0 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { ImageDBModule } from '../../collections/image-db/image-db.module';
import { PreferenceDbModule } from '../../collections/preference-db/preference-db.module';
import { ImageManagerModule } from '../image/image.module';
import { IngestConsumer } from './ingest.consumer';
import { IngestService } from './ingest.service';
@Module({
imports: [
ImageDBModule,
ImageManagerModule,
PreferenceDbModule,
BullModule.registerQueue({
name: 'image-ingest',
}),
],
providers: [IngestConsumer, IngestService],
exports: [BullModule, IngestService],
})
export class IngestManagerModule {}

View File

@@ -1,6 +1,4 @@
import { InjectQueue } from '@nestjs/bull';
import { Controller, Get, Logger } from '@nestjs/common';
import type { Queue } from 'bull';
import { NoPermissions } from '../../../decorators/permissions.decorator';
import { ReturnsAnything } from '../../../decorators/returns.decorator';
@@ -9,27 +7,11 @@ import { ReturnsAnything } from '../../../decorators/returns.decorator';
export class ExperimentController {
private readonly logger = new Logger(ExperimentController.name);
constructor(
@InjectQueue('image-ingest') private readonly ingestQueue: Queue,
) {
this.logger.log('experiment consumer started');
this.logger.error('experiment consumer started');
console.log(this.logger);
}
constructor() {}
@Get()
@ReturnsAnything()
async testRoute(): Promise<any> {
console.log('Create job');
const job = await this.ingestQueue.add({
foo: Buffer.from('aaaaaheleool'),
});
console.log('Job created', job.id);
const result = await job.finished();
console.log('Job finished', result);
return 'ok';
}
}

View File

@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { PicsurLoggerModule } from '../../../logger/logger.module';
import { IngestManagerModule } from '../../../managers/ingest/ingest.module';
import { ImageManagerModule } from '../../../managers/image/image-manager.module';
import { ExperimentController } from './experiment.controller';
// This is comletely useless module, but is used for testing
// TODO: remove when out of beta
@Module({
imports: [IngestManagerModule, PicsurLoggerModule],
imports: [ImageManagerModule, PicsurLoggerModule],
controllers: [ExperimentController]
})
export class ExperimentModule {}

View File

@@ -30,8 +30,8 @@ import {
} from '../../decorators/permissions.decorator';
import { ReqUserID } from '../../decorators/request-user.decorator';
import { Returns, ReturnsAnything } from '../../decorators/returns.decorator';
import { ImageManagerService } from '../../managers/image/image.service';
import { IngestService } from '../../managers/ingest/ingest.service';
import { ImageManagerService } from '../../managers/image/image-manager.service';
import { IngestService } from '../../managers/image/ingest.service';
import { GetNextAsync } from '../../util/iterator';
@Controller('api/image')
@RequiredPermissions(Permission.ImageUpload)

View File

@@ -13,7 +13,8 @@ import { ImageFullIdParam } from '../../decorators/image-id/image-full-id.decora
import { ImageIdParam } from '../../decorators/image-id/image-id.decorator';
import { RequiredPermissions } from '../../decorators/permissions.decorator';
import { Returns } from '../../decorators/returns.decorator';
import { ImageManagerService } from '../../managers/image/image.service';
import { ConvertService } from '../../managers/image/convert.service';
import { ImageManagerService } from '../../managers/image/image-manager.service';
import type { ImageFullId } from '../../models/constants/image-full-id.const';
import { Permission } from '../../models/constants/permissions.const';
import { EUserBackend2EUser } from '../../models/transformers/user.transformer';
@@ -29,6 +30,7 @@ export class ImageController {
constructor(
private readonly imagesService: ImageManagerService,
private readonly userService: UserDbService,
private readonly convertService: ConvertService,
) {}
@Head(':id')
@@ -67,7 +69,7 @@ export class ImageController {
}
const image = ThrowIfFailed(
await this.imagesService.getConverted(
await this.convertService.convertPromise(
fullid.id,
fullid.filetype,
params,

View File

@@ -1,9 +1,7 @@
import { Module } from '@nestjs/common';
import { IngestFileDbModule } from '../../collections/ingest-file-db/ingest-file-db.module';
import { UserDbModule } from '../../collections/user-db/user-db.module';
import { DecoratorsModule } from '../../decorators/decorators.module';
import { ImageManagerModule } from '../../managers/image/image.module';
import { IngestManagerModule } from '../../managers/ingest/ingest.module';
import { ImageManagerModule } from '../../managers/image/image-manager.module';
import { ImageManageController } from './image-manage.controller';
import { ImageController } from './image.controller';
@@ -11,8 +9,6 @@ import { ImageController } from './image.controller';
imports: [
ImageManagerModule,
UserDbModule,
IngestFileDbModule,
IngestManagerModule,
DecoratorsModule,
],
controllers: [ImageController, ImageManageController],

View File

@@ -17,13 +17,7 @@ export const SysPreferenceUI: {
[SysPreference.RemoveDerivativesAfter]: {
name: 'Cached Images Expiry Time',
helpText:
'Time before cached images are deleted. This does not affect the original image. Set to 0 to disable.',
category: 'Image Processing',
},
[SysPreference.SaveDerivatives]: {
name: 'Cache Converted Images',
helpText:
'Cache converted images, this will reduce the time it takes to load images. It does however use more disk space.',
'Time before cached converted images are deleted. This does not affect the original image. A lower cache time will save on disk space but cost more cpu. Set to 0 to disable.',
category: 'Image Processing',
},
[SysPreference.AllowEditing]: {

View File

@@ -13,7 +13,6 @@ export enum SysPreference {
JwtExpiresIn = 'jwt_expires_in',
BCryptStrength = 'bcrypt_strength',
SaveDerivatives = 'save_derivatives',
RemoveDerivativesAfter = 'remove_derivatives_after',
AllowEditing = 'allow_editing',
@@ -41,7 +40,6 @@ export const SysPreferenceValueTypes: {
[SysPreference.BCryptStrength]: 'number',
[SysPreference.RemoveDerivativesAfter]: 'string',
[SysPreference.SaveDerivatives]: 'boolean',
[SysPreference.AllowEditing]: 'boolean',
[SysPreference.ConversionTimeLimit]: 'string',
@@ -63,8 +61,7 @@ export const SysPreferenceValidators: {
[SysPreference.JwtExpiresIn]: IsValidMS(),
[SysPreference.BCryptStrength]: IsPosInt(),
[SysPreference.RemoveDerivativesAfter]: IsValidMS(),
[SysPreference.SaveDerivatives]: z.boolean(),
[SysPreference.RemoveDerivativesAfter]: IsValidMS(60000),
[SysPreference.AllowEditing]: z.boolean(),
[SysPreference.ConversionTimeLimit]: IsValidMS(),

View File

@@ -1,7 +1,7 @@
import ms from 'ms';
import { z } from 'zod';
export const IsValidMS = () =>
export const IsValidMS = (min = 0) =>
z.preprocess(
(v: any) => {
try {
@@ -17,5 +17,5 @@ export const IsValidMS = () =>
}),
})
.int()
.min(0),
.min(min),
);

147
yarn.lock
View File

@@ -39,17 +39,7 @@ __metadata:
languageName: node
linkType: hard
"@angular-devkit/architect@npm:0.1402.2, @angular-devkit/architect@npm:>=0.1400.0 < 0.1500.0":
version: 0.1402.2
resolution: "@angular-devkit/architect@npm:0.1402.2"
dependencies:
"@angular-devkit/core": 14.2.2
rxjs: 6.6.7
checksum: a014bbd941582ad4a263b9d8870accef78c5e26528ba3f7a760c860dbb4ba6b66216361f562da591b579662f2710fb5810d748610aa17b7b4c17f4caa08794e3
languageName: node
linkType: hard
"@angular-devkit/architect@npm:0.1402.3":
"@angular-devkit/architect@npm:0.1402.3, @angular-devkit/architect@npm:>=0.1400.0 < 0.1500.0":
version: 0.1402.3
resolution: "@angular-devkit/architect@npm:0.1402.3"
dependencies:
@@ -59,7 +49,7 @@ __metadata:
languageName: node
linkType: hard
"@angular-devkit/build-angular@npm:14.2.3":
"@angular-devkit/build-angular@npm:14.2.3, @angular-devkit/build-angular@npm:^14.0.0":
version: 14.2.3
resolution: "@angular-devkit/build-angular@npm:14.2.3"
dependencies:
@@ -155,115 +145,6 @@ __metadata:
languageName: node
linkType: hard
"@angular-devkit/build-angular@npm:^14.0.0":
version: 14.2.2
resolution: "@angular-devkit/build-angular@npm:14.2.2"
dependencies:
"@ampproject/remapping": 2.2.0
"@angular-devkit/architect": 0.1402.2
"@angular-devkit/build-webpack": 0.1402.2
"@angular-devkit/core": 14.2.2
"@babel/core": 7.18.10
"@babel/generator": 7.18.12
"@babel/helper-annotate-as-pure": 7.18.6
"@babel/plugin-proposal-async-generator-functions": 7.18.10
"@babel/plugin-transform-async-to-generator": 7.18.6
"@babel/plugin-transform-runtime": 7.18.10
"@babel/preset-env": 7.18.10
"@babel/runtime": 7.18.9
"@babel/template": 7.18.10
"@discoveryjs/json-ext": 0.5.7
"@ngtools/webpack": 14.2.2
ansi-colors: 4.1.3
babel-loader: 8.2.5
babel-plugin-istanbul: 6.1.1
browserslist: ^4.9.1
cacache: 16.1.2
copy-webpack-plugin: 11.0.0
critters: 0.0.16
css-loader: 6.7.1
esbuild: 0.15.5
esbuild-wasm: 0.15.5
glob: 8.0.3
https-proxy-agent: 5.0.1
inquirer: 8.2.4
jsonc-parser: 3.1.0
karma-source-map-support: 1.4.0
less: 4.1.3
less-loader: 11.0.0
license-webpack-plugin: 4.0.2
loader-utils: 3.2.0
mini-css-extract-plugin: 2.6.1
minimatch: 5.1.0
open: 8.4.0
ora: 5.4.1
parse5-html-rewriting-stream: 6.0.1
piscina: 3.2.0
postcss: 8.4.16
postcss-import: 15.0.0
postcss-loader: 7.0.1
postcss-preset-env: 7.8.0
regenerator-runtime: 0.13.9
resolve-url-loader: 5.0.0
rxjs: 6.6.7
sass: 1.54.4
sass-loader: 13.0.2
semver: 7.3.7
source-map-loader: 4.0.0
source-map-support: 0.5.21
stylus: 0.59.0
stylus-loader: 7.0.0
terser: 5.14.2
text-table: 0.2.0
tree-kill: 1.2.2
tslib: 2.4.0
webpack: 5.74.0
webpack-dev-middleware: 5.3.3
webpack-dev-server: 4.11.0
webpack-merge: 5.8.0
webpack-subresource-integrity: 5.1.0
peerDependencies:
"@angular/compiler-cli": ^14.0.0
"@angular/localize": ^14.0.0
"@angular/service-worker": ^14.0.0
karma: ^6.3.0
ng-packagr: ^14.0.0
protractor: ^7.0.0
tailwindcss: ^2.0.0 || ^3.0.0
typescript: ">=4.6.2 <4.9"
dependenciesMeta:
esbuild:
optional: true
peerDependenciesMeta:
"@angular/localize":
optional: true
"@angular/service-worker":
optional: true
karma:
optional: true
ng-packagr:
optional: true
protractor:
optional: true
tailwindcss:
optional: true
checksum: 3829654f52b26bc7bd5c753f867a0b24d649e0b650c24cd6ff0ecbfc0708bab8403c64ece40a349147d2104dd21472ac71e7cf7b8e58e1622f430fab99701086
languageName: node
linkType: hard
"@angular-devkit/build-webpack@npm:0.1402.2":
version: 0.1402.2
resolution: "@angular-devkit/build-webpack@npm:0.1402.2"
dependencies:
"@angular-devkit/architect": 0.1402.2
rxjs: 6.6.7
peerDependencies:
webpack: ^5.30.0
webpack-dev-server: ^4.0.0
checksum: fb97fa5eceb043cefce63ee4a8a598496002506c05423437914b721c41e78408750d28b42b9dc1d4594c1c599ea5c28b7be1903b8a3041c46c1e9dd1f2d7575c
languageName: node
linkType: hard
"@angular-devkit/build-webpack@npm:0.1402.3":
version: 0.1402.3
resolution: "@angular-devkit/build-webpack@npm:0.1402.3"
@@ -295,7 +176,7 @@ __metadata:
languageName: node
linkType: hard
"@angular-devkit/core@npm:14.2.2, @angular-devkit/core@npm:^14.0.0":
"@angular-devkit/core@npm:14.2.2":
version: 14.2.2
resolution: "@angular-devkit/core@npm:14.2.2"
dependencies:
@@ -313,7 +194,7 @@ __metadata:
languageName: node
linkType: hard
"@angular-devkit/core@npm:14.2.3":
"@angular-devkit/core@npm:14.2.3, @angular-devkit/core@npm:^14.0.0":
version: 14.2.3
resolution: "@angular-devkit/core@npm:14.2.3"
dependencies:
@@ -2745,17 +2626,6 @@ __metadata:
languageName: node
linkType: hard
"@ngtools/webpack@npm:14.2.2":
version: 14.2.2
resolution: "@ngtools/webpack@npm:14.2.2"
peerDependencies:
"@angular/compiler-cli": ^14.0.0
typescript: ">=4.6.2 <4.9"
webpack: ^5.54.0
checksum: fce1268f3686ed1f974161bed3662915a98a90c75b7bc8fb166fea346794351ef92bc5bb3cb95714f9d235beaa1b14c8856d4ca8d7eac431d30918a96d404df8
languageName: node
linkType: hard
"@ngtools/webpack@npm:14.2.3":
version: 14.2.3
resolution: "@ngtools/webpack@npm:14.2.3"
@@ -3177,14 +3047,7 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:*":
version: 18.7.16
resolution: "@types/node@npm:18.7.16"
checksum: 01a3d35c764a3f0e7370b56e1ad4203731131883c65784e020009014171b3f53c4649cde6c7aa4f1026b907ee87ef6ae6ece2bc518151dc7b81100fe8b1db3ad
languageName: node
linkType: hard
"@types/node@npm:>=10.0.0, @types/node@npm:^18.7.18":
"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:^18.7.18":
version: 18.7.18
resolution: "@types/node@npm:18.7.18"
checksum: 8aec61f0f96e2a69ce51f1f40f949ca578bbb4fe05d7c0b8ce3aeeb848e90f755837f17f6ac132ca404d974fe9b2974150ad3b4984fc9dc7c3ceddb10bae0167