213 lines
7.6 KiB
TypeScript
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 };
|
|
}
|
|
}
|