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, DefaultValuePipe, ParseIntPipe } 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 }; } }