199 lines
5.8 KiB
JavaScript
199 lines
5.8 KiB
JavaScript
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<Array<object>>} 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;
|
|
}
|