import { createHash } from 'node:crypto'; import { LIST_BY_MANIFEST } from '../../sql/manifestattachment.js'; import request from '../request.js'; import generateDocumentThumb from '../../util/generateDocumentThumb.js'; import { parseMeta } from '../manifestattachment/listByManifestId.js'; const ALLOWED_MIMETYPES = new Set([ 'application/pdf', 'text/plain', ]); export function shapeAttachmentMeta(row) { return { id: row.id, manifestId: row.manifestId, fileId: row.fileId, sort: row.sort, hash: row.hash, mimetype: row.mimetype, name: row.name, size: Number(row.size), created: row.created, modified: row.modified, thumbavif: row.thumbavif ? (Buffer.isBuffer(row.thumbavif) ? row.thumbavif.toString('base64') : row.thumbavif) : null, thumbx: row.thumbx ?? null, thumby: row.thumby ?? null, meta: parseMeta(row.meta), }; } /** * Loads attachment metadata (without binary content) for a manifest. */ export async function loadAttachments(pool, tx, manifestId) { const result = await request(pool, tx) .input('manifestId', manifestId) .query(LIST_BY_MANIFEST); return result.recordset.map(shapeAttachmentMeta); } function normalizeAttachment(raw, index) { if (typeof raw !== 'object' || raw === null) { throw new Error('Invalid file'); } const mimetype = typeof raw.mimetype === 'string' ? raw.mimetype.trim().toLowerCase() : ''; if (!ALLOWED_MIMETYPES.has(mimetype)) { throw new Error('Invalid file'); } const name = typeof raw.name === 'string' ? raw.name.trim() : ''; if (!name) { throw new Error('Invalid file'); } if (raw.content == null) { throw new Error('Invalid file'); } let content; if (Buffer.isBuffer(raw.content)) { content = raw.content; } else if (typeof raw.content === 'string') { const trimmed = raw.content.trim(); if (mimetype === 'application/pdf' || trimmed.startsWith('JVBER') || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) { try { content = Buffer.from(trimmed, 'base64'); } catch { content = Buffer.from(raw.content, 'utf8'); } } else { content = Buffer.from(raw.content, 'utf8'); } } else { content = Buffer.from(raw.content); } const size = Number.isFinite(raw.size) && raw.size >= 0 ? Number(raw.size) : content.length; const hash = typeof raw.hash === 'string' && raw.hash.trim() ? raw.hash.trim() : createHash('sha256').update(content).digest('hex'); const parseDate = (value) => { if (value instanceof Date && !isNaN(value)) return value; if (typeof value === 'string' || typeof value === 'number') { const parsed = new Date(value); return isNaN(parsed) ? null : parsed; } return null; }; const created = parseDate(raw.created ?? raw.createdAt) ?? new Date(); const modified = parseDate(raw.modified ?? raw.modifiedAt) ?? created; let meta = null; if (typeof raw.meta === 'object' && raw.meta !== null && !Array.isArray(raw.meta)) { meta = {}; for (const [key, value] of Object.entries(raw.meta)) { if (value === undefined || value === null) continue; // The client shall not provide thumbnails; thumbs are strictly generated server-side. if (key === 'thumbnail') continue; meta[key] = String(value); } if (Object.keys(meta).length === 0) { meta = null; } } return { mimetype, name, content, size, hash, created, modified, meta, sort: Number.isFinite(raw.sort) ? Number(raw.sort) : null, }; } /** * Inserts pdf/txt attachments for a delivery (manifest) inside an existing * transaction. Sort orders continue after the current maximum. * * @param {object} pool * @param {object} tx - Active transaction. * @param {object} sql - SQL statements from delivery.js. * @param {number} manifestId * @param {Array<{mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string}>} [files] * @returns {Promise>} Attachment metadata rows. */ export async function insertAttachments(pool, tx, sql, manifestId, files) { const attachments = (files ?? []).map(normalizeAttachment); const sortRes = await request(pool, tx) .input('manifestId', manifestId) .query(sql.GET_NEXT_ATTACHMENT_SORT); let nextSort = sortRes.recordset[0].sort; const results = []; for (const a of attachments) { const sort = a.sort ?? nextSort; nextSort = Math.max(nextSort, sort) + 1; // Generate thumbnail server-side (e.g. pdftocairo + sharp for PDF) const { thumbavif, thumbx, thumby } = await generateDocumentThumb(a.content, a.mimetype, { filename: a.name }); const fileRes = await request(pool, tx) .input('hash', a.hash) .input('mimetype', a.mimetype) .input('name', a.name) .input('size', a.size) .input('created', a.created) .input('modified', a.modified) .input('content', a.content) .input('thumbavif', thumbavif) .input('thumbx', thumbx) .input('thumby', thumby) .input('meta', a.meta === null ? null : JSON.stringify(a.meta)) .query(sql.CREATE_FILE); const fileId = fileRes.recordset[0].id; await request(pool, tx) .input('manifestId', manifestId) .input('fileId', fileId) .input('sort', sort) .query(sql.CREATE_ATTACHMENT); results.push({ manifestId, fileId, sort, hash: a.hash, mimetype: a.mimetype, name: a.name, size: a.size, created: a.created, modified: a.modified, thumbavif: thumbavif ? (Buffer.isBuffer(thumbavif) ? thumbavif.toString('base64') : thumbavif) : null, thumbx: thumbx ?? null, thumby: thumby ?? null, meta: a.meta, }); } return results; }