targo-backend/src/modules/attachments/controllers/attachments.controller.ts

213 lines
7.6 KiB
TypeScript

import { FileInterceptor } from "@nestjs/platform-express";
import { DiskStorageService } from "../services/disk-storage.service";
import {
Controller,NotFoundException, UseInterceptors, Post, Get, Param, Res,
UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete,
Query,
} from "@nestjs/common";
import { maxUploadBytes, allowedMimes } from "../config/upload.config";
import { memoryStorage } from 'multer';
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
import { Readable } from "node:stream";
import { PrismaService } from "src/prisma/prisma.service";
import { UploadMetaAttachmentsDto } from "../dtos/upload-meta-attachments.dto";
import { resolveAttachmentsRoot } from "src/config/attachment.config";
import * as path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createReadStream } from "node:fs";
import { Response } from 'express';
import { VariantsQueue } from "../services/variants.queue";
import { AdminSearchDto } from "../dtos/admin-search.dto";
@Controller('attachments')
export class AttachmentsController {
constructor(
private readonly disk: DiskStorageService,
private readonly prisma: PrismaService,
private readonly variantsQ: VariantsQueue,
) {}
@Get(':id')
async getById(
@Param('id') id: string,
@Query('variant') variant: string | undefined,
@Res() res: Response,
) {
const num_id = Number(id);
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid id');
const attachment = await this.prisma.attachments.findUnique({
where: { id: num_id },
include: { blob: true },
});
if (!attachment) throw new NotFoundException();
const relative = variant ? `${attachment.blob.storage_path}.${variant}` : attachment.blob.storage_path;
const abs = path.join(resolveAttachmentsRoot(), relative);
let stat;
try {
stat = await fsp.stat(abs);
}catch {
throw new NotFoundException('File not found');
}
let mime = attachment.blob.mime;
try {
const kind = await fileTypeFromFile(abs);
if(kind?.mime) mime = kind.mime;
} catch {}
res.set('Content-Type', mime);
res.set('Content-Length', String(stat.size));
res.set('ETag', `"sha256-${attachment.blob.sha256}${variant ? '.'+variant : ''}"`);
res.set('Last-Modified', stat.mtime.toUTCString());
res.set('Cache-Control', 'private, max-age=31536000, immutable');
res.set('X-Content-Type-Options', 'nosniff');
createReadStream(abs).pipe(res);
}
@Get('variants/:id')
async listVariants(@Param('id')id: string) {
const num_id = Number(id);
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid variant id');
return this.prisma.attachmentVariants.findMany({
where: { attachment_id: num_id },
orderBy: { variant: 'asc'},
select: { variant: true, bytes: true, width: true, height: true, patch: true, created_at: true },
});
}
@Delete(':id')
async remove(@Param('id') id: string) {
const result = await this.prisma.$transaction(async (tx) => {
const att = await tx.attachments.findUnique({ where: { id: Number(id) } });
if (!att) throw new NotFoundException();
// soft-delete
await tx.attachments.update({
where: { id: Number(id) },
data: { status: 'DELETED' },
});
// decrement refcount
const dec = await tx.$executeRaw
`UPDATE "Blobs" SET refcount = refcount - 1
WHERE sha256 = ${att.sha256} AND refcount > 0`
;
return { ok: true, decremented: dec > 0 };
});
return result;
}
@Post()
@UseInterceptors(
FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: maxUploadBytes() }})
)
async upload(
@UploadedFile() file?: Express.Multer.File,
@Body() meta?: UploadMetaAttachmentsDto,
) {
if(!file) throw new BadRequestException('No file found');
//magic detection using binary signature
const kind = await fileTypeFromBuffer(file.buffer).catch(() => null);
const detected_mime = kind?.mime || file.mimetype || 'application/octet-stream';
//strict whitelist
if(!allowedMimes().includes(detected_mime)) {
throw new UnsupportedMediaTypeException(`This type is not supported: ${detected_mime}`);
}
//Saving FS (hash + CAS + unDupes)
const stream = Readable.from(file.buffer);
const { sha256, storage_path, size } = await this.disk.saveStreamAndHash(stream);
const now = new Date();
const attachment = await this.prisma.$transaction(async (tx) => {
//upsert blob: +1 ref
await tx.blobs.upsert({
where: { sha256 },
create: {
sha256,
storage_path: storage_path,
size,
mime: detected_mime,
refcount: 1,
created_at: now,
},
update: { //only increment, does not change the storage path
refcount: { increment: 1 },
mime: detected_mime, //update mime and size to keep last image
size,
},
});
const att = await tx.attachments.create({
data: {
sha256,
owner_type: meta?.owner_type ?? 'EXPENSE',
owner_id: meta?.owner_id ?? 'unknown',
original_name: file.originalname,
status: 'ACTIVE',
retention_policy: (meta?.retention_policy ?? 'EXPENSE_7Y') as any,
created_by: meta?.created_by ?? 'system',
created_at: now,
},
});
return att;
});
await this.variantsQ.enqueue(attachment.id, detected_mime);
return {
ok: true,
id: attachment.id,
sha256,
storage_path: storage_path,
size,
mime: detected_mime,
original_name: file.originalname,
owner_type: attachment.owner_type,
owner_id: attachment.owner_id,
};
}
@Get('/admin/search')
async adminSearch(
@Query() query: AdminSearchDto ) {
const where: any = {};
if (query.owner_type) where.owner_type = query.owner_type;
if (query.owner_id) where.owner_id = query.owner_id;
if (query.date_from || query.date_to) {
where.created_at = {};
if (query.date_from) where.created_at.gte = new Date(query.date_from + 'T00:00:00Z');
if (query.date_to) where.created_at.lte = new Date(query.date_to + 'T23:59:59Z');
}
const page = query.page ?? 1;
const page_size = query.page_size ?? 50;
const skip = (page - 1)* page_size;
const take = page_size;
const [items, total] = await this.prisma.$transaction([
this.prisma.attachments.findMany({
where,
orderBy: { created_at: 'desc' },
skip, take,
include: {
blob: {
select: { mime: true, size: true, storage_path: true, sha256: true },
},
},
}),
this.prisma.attachments.count({ where }),
]);
return { page, page_size: take, total, items };
}
}