From 014f58f78a60807265e9e27358741e25e42bdeb8 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 15 Aug 2025 13:30:07 -0400 Subject: [PATCH] feat(attachments): added prisma models for blobs and attachments and basic setup for stream and hash --- prisma/schema.prisma | 41 +++++++++++++++ src/attachments/disk-storage.service.ts | 69 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/attachments/disk-storage.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 53a130f..6e4646a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -266,6 +266,47 @@ model OAuthSessions { @@map("oauth_sessions") } +model Blobs { + sha256 String @id @db.Char(64) + size Int + mime String + storage_path String + refcount Int @default(0) + created_at DateTime @default(now()) + + attachments Attachments[] + + @@map("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()) + + @@index([owner_type, owner_id, created_at]) + @@index([sha256]) + @@map("attachments") +} + +enum AttachmentStatus { + ACTIVE + DELETED +} + +enum RetentionPolicy { + EXPENSE_7Y + TICKET_2Y + PROFILE_KEEP_LAST3 +} + enum Roles { ADMIN SUPERVISOR diff --git a/src/attachments/disk-storage.service.ts b/src/attachments/disk-storage.service.ts new file mode 100644 index 0000000..3427926 --- /dev/null +++ b/src/attachments/disk-storage.service.ts @@ -0,0 +1,69 @@ +import { createHash } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; +import { createWriteStream, statSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { ATT_TMP_DIR, resolveAttachmentsRoot } from 'src/config/attachment.config'; + +export type SaveResult = { sha256:string, storagePath:string, size:number}; + +export class DiskStorageService { + private root = resolveAttachmentsRoot(); + + private casPath(hash: string) { + const a = hash.slice(0,2), b = hash.slice(2,4); + return `sha256/${a}/${b}/${hash}`; //relatif pour stockage dans la DB + } + + //chemin absolue du storage + getAbsolutePath(storagePathRel: string) { + return join(this.root, storagePathRel); + } + + async exists(storagePathRel: string) { + try { + statSync(this.getAbsolutePath(storagePathRel)); + return true; + }catch { + return false; + } + } + + //adds file and hash it + async saveStreamAndHash(input: NodeJS.ReadableStream): Promise { + // 1- writing in ROOT:/_tmp while streaming and hashing + const tmpDir = ATT_TMP_DIR(); + await fsp.mkdir(tmpDir, { recursive: true }); + const tmpPath = join(tmpDir, `up_${Date.now()}_${Math.random().toString(36).slice(2)}`); + + const hash = createHash('sha256'); + const tmpOut = createWriteStream(tmpPath); + input.on('date', (chunk) => hash.update(chunk)); + await pipeline(input, tmpOut); //await end of writing stream + + const sha = hash.digest('hex'); + const rel = this.casPath(sha); + const finalAbs = this.getAbsolutePath(rel); + + // 2- is there is no destination => move (atomic renaming on the same volume) + if(!existsSync(finalAbs)) { + await fsp.mkdir(dirname(finalAbs), { recursive:true }); + try { + await fsp.rename(tmpPath, finalAbs); + }catch (e) { + //if someone is faster and used the same hash + if(existsSync(finalAbs)) { + await fsp.rm(tmpPath, { force:true }); + } else { + throw e; + } + } + } else { + //remove duplicata if already exists + await fsp.rm(tmpPath, { force:true }); + } + + const size = statSync(finalAbs).size; + return { sha256: sha, storagePath: rel, size }; + } +} \ No newline at end of file