feat(attachments): attachments module with minor fixes to attachments prisma model.
This commit is contained in:
parent
014f58f78a
commit
c45284fd84
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -14,13 +14,15 @@
|
|||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.1.6",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@prisma/client": "^6.14.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"express-session": "^1.18.2",
|
||||
"file-type": "^21.0.0",
|
||||
"multer": "^2.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-openidconnect": "^0.1.2",
|
||||
|
|
@ -38,7 +40,8 @@
|
|||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.17.2",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-openidconnect": "^0.1.3",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
|
@ -2858,10 +2861,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express": {
|
||||
"version": "11.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.5.tgz",
|
||||
"integrity": "sha512-OsoiUBY9Shs5IG3uvDIt9/IDfY5OlvWBESuB/K4Eun8xILw1EK5d5qMfC3d2sIJ+kA3l+kBR1d/RuzH7VprLIg==",
|
||||
"license": "MIT",
|
||||
"version": "11.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.6.tgz",
|
||||
"integrity": "sha512-HErwPmKnk+loTq8qzu1up+k7FC6Kqa8x6lJ4cDw77KnTxLzsCaPt+jBvOq6UfICmfqcqCCf3dKXg+aObQp+kIQ==",
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"express": "5.1.0",
|
||||
|
|
@ -3805,10 +3807,19 @@
|
|||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.16.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.4.tgz",
|
||||
"integrity": "sha512-PYRhNtZdm2wH/NT2k/oAJ6/f2VD2N2Dag0lGlx2vWgMSJXGNmlce5MiTQzoWAiIJtso30mjnfQCOKVH+kAQC/g==",
|
||||
"version": "22.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.2.tgz",
|
||||
"integrity": "sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -9287,7 +9298,6 @@
|
|||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
|
|
|
|||
|
|
@ -45,13 +45,15 @@
|
|||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.1.6",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@prisma/client": "^6.14.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"express-session": "^1.18.2",
|
||||
"file-type": "^21.0.0",
|
||||
"multer": "^2.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-openidconnect": "^0.1.2",
|
||||
|
|
@ -69,7 +71,8 @@
|
|||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.17.2",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-openidconnect": "^0.1.3",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "public"."AttachmentStatus" AS ENUM ('ACTIVE', 'DELETED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."RetentionPolicy" AS ENUM ('EXPENSE_7Y', 'TICKET_2Y', 'PROFILE_KEEP_LAST3');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."blobs" (
|
||||
"sha256" CHAR(64) NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"storage_path" TEXT NOT NULL,
|
||||
"refcount" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "blobs_pkey" PRIMARY KEY ("sha256")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."attachments" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"sha256" CHAR(64) NOT NULL,
|
||||
"owner_type" TEXT NOT NULL,
|
||||
"owner_id" TEXT NOT NULL,
|
||||
"orignal_name" TEXT NOT NULL,
|
||||
"status" "public"."AttachmentStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"relation_policy" "public"."RetentionPolicy" NOT NULL,
|
||||
"created_by" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "attachments_owner_type_owner_id_created_at_idx" ON "public"."attachments"("owner_type", "owner_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "attachments_sha256_idx" ON "public"."attachments"("sha256");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."attachments" ADD CONSTRAINT "attachments_sha256_fkey" FOREIGN KEY ("sha256") REFERENCES "public"."blobs"("sha256") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
2
prisma/migrations/20250818121746_/migration.sql
Normal file
2
prisma/migrations/20250818121746_/migration.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "blobs"
|
||||
ADD CONSTRAINT blobs_refcount_nonneg CHECK (refcount >= 0);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `orignal_name` on the `attachments` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `relation_policy` on the `attachments` table. All the data in the column will be lost.
|
||||
- Added the required column `original_name` to the `attachments` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `retention_policy` to the `attachments` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."attachments" DROP COLUMN "orignal_name",
|
||||
DROP COLUMN "relation_policy",
|
||||
ADD COLUMN "original_name" TEXT NOT NULL,
|
||||
ADD COLUMN "retention_policy" "public"."RetentionPolicy" NOT NULL;
|
||||
|
|
@ -280,16 +280,16 @@ model Blobs {
|
|||
}
|
||||
|
||||
model Attachments {
|
||||
id Int @id @default(autoincrement())
|
||||
sha256 String @db.Char(64)
|
||||
blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade)
|
||||
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
|
||||
owner_id String //expense_id, employee_id, etc
|
||||
orignal_name String
|
||||
status AttachmentStatus @default(ACTIVE)
|
||||
relation_policy RetentionPolicy
|
||||
created_by String
|
||||
created_at DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
sha256 String @db.Char(64)
|
||||
blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade)
|
||||
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
|
||||
owner_id String //expense_id, employee_id, etc
|
||||
original_name String
|
||||
status AttachmentStatus @default(ACTIVE)
|
||||
retention_policy RetentionPolicy
|
||||
created_by String
|
||||
created_at DateTime @default(now())
|
||||
|
||||
@@index([owner_type, owner_id, created_at])
|
||||
@@index([sha256])
|
||||
|
|
|
|||
5
src/modules/attachments/config/upload.config.ts
Normal file
5
src/modules/attachments/config/upload.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const maxUploadBytes = () =>
|
||||
(Number(process.env.MAX_UPLOAD_MB || 25)) * 1024 * 1024;
|
||||
|
||||
export const allowedMimes = () =>
|
||||
(process.env.ALLOWED_MIME || 'image/jpeg,image/png,image/webp,application/pdf').split(',').map(s =>s.trim()).filter(Boolean);
|
||||
144
src/modules/attachments/controllers/attachments.controller.ts
Normal file
144
src/modules/attachments/controllers/attachments.controller.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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, NotImplementedException
|
||||
} 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 { casPathFor } from "../utils/cas.util";
|
||||
import * as path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { createReadStream } from "node:fs";
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('attachments')
|
||||
export class AttachementsController {
|
||||
constructor(
|
||||
private readonly disk: DiskStorageService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get(':id')
|
||||
async getById(@Param('id') id: string, @Res() res: Response) {
|
||||
const att = await this.prisma.attachments.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: { blob: true },
|
||||
});
|
||||
if (!att) throw new NotFoundException();
|
||||
|
||||
const abs = path.join(resolveAttachmentsRoot(), att.blob.storage_path);
|
||||
const stat = await fsp.stat(abs);
|
||||
|
||||
res.setHeader('Content-Type', att.blob.mime);
|
||||
res.setHeader('Content-Length', String(stat.size));
|
||||
res.setHeader('ETag', `"sha256-${att.blob.sha256}"`);
|
||||
res.setHeader('Last-Modified', stat.mtime.toUTCString());
|
||||
res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
createReadStream(abs).pipe(res);
|
||||
}
|
||||
|
||||
// DEV version, uncomment once connected to DB and distant server
|
||||
@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.$executeRawUnsafe(
|
||||
`UPDATE "Blobs" SET refcount = refcount - 1
|
||||
WHERE sha256 = $1 AND refcount > 0`, att.sha256,
|
||||
);
|
||||
|
||||
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 detectedMime = kind?.mime || file.mimetype || 'application/octet-stream';
|
||||
|
||||
//strict whitelist
|
||||
if(!allowedMimes().includes(detectedMime)) {
|
||||
throw new UnsupportedMediaTypeException(`This type is not supported: ${detectedMime}`);
|
||||
}
|
||||
|
||||
//Saving FS (hash + CAS + unDupes)
|
||||
const stream = Readable.from(file.buffer);
|
||||
const { sha256, storagePath, 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: storagePath,
|
||||
size,
|
||||
mime: detectedMime,
|
||||
refcount: 1,
|
||||
created_at: now,
|
||||
},
|
||||
update: { //only increment, does not change the storage path
|
||||
refcount: { increment: 1 },
|
||||
mime: detectedMime, //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;
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: attachment.id,
|
||||
sha256,
|
||||
storagePath: storagePath,
|
||||
size,
|
||||
mime: detectedMime,
|
||||
original_name: file.originalname,
|
||||
owner_type: attachment.owner_type,
|
||||
owner_id: attachment.owner_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export class UploadMetaAttachmentsDto {
|
||||
owner_type!: string;
|
||||
owner_id!: string;
|
||||
retention_policy!: 'EXPENSE_7Y' | 'TICKET_2Y' | 'PROFILE_KEEP_LAST3';
|
||||
created_by!: string;
|
||||
}
|
||||
4
src/modules/attachments/utils/cas.util.ts
Normal file
4
src/modules/attachments/utils/cas.util.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function casPathFor(hash: string) {
|
||||
const a = hash.slice(0,2), b = hash.slice(2,4);
|
||||
return `sha256/${a}/${b}/${hash}`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user