From bac6f2a801addd9e7d0fa6064e0423ff4d7fe24b Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 18 Sep 2026 07:13:56 +0200 Subject: [PATCH] u --- src/store/delivery/attachments.js | 22 ++++++-- src/store/manifestattachment/add.js | 18 ++---- src/util/generateDocumentThumb.js | 85 +++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 src/util/generateDocumentThumb.js diff --git a/src/store/delivery/attachments.js b/src/store/delivery/attachments.js index 07ba30b..cd638a2 100644 --- a/src/store/delivery/attachments.js +++ b/src/store/delivery/attachments.js @@ -1,7 +1,7 @@ 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([ @@ -21,7 +21,9 @@ export function shapeAttachmentMeta(row) { size: Number(row.size), created: row.created, modified: row.modified, - thumbavif: row.thumbavif ?? null, + 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), @@ -88,6 +90,8 @@ function normalizeAttachment(raw, index) { 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) { @@ -132,6 +136,9 @@ export async function insertAttachments(pool, tx, sql, manifestId, files) { 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); + const fileRes = await request(pool, tx) .input('hash', a.hash) .input('mimetype', a.mimetype) @@ -140,9 +147,9 @@ export async function insertAttachments(pool, tx, sql, manifestId, files) { .input('created', a.created) .input('modified', a.modified) .input('content', a.content) - .input('thumbavif', null) - .input('thumbx', null) - .input('thumby', null) + .input('thumbavif', thumbavif) + .input('thumbx', thumbx) + .input('thumby', thumby) .input('meta', a.meta === null ? null : JSON.stringify(a.meta)) .query(sql.CREATE_FILE); @@ -164,6 +171,11 @@ export async function insertAttachments(pool, tx, sql, manifestId, files) { 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, }); } diff --git a/src/store/manifestattachment/add.js b/src/store/manifestattachment/add.js index 3bc3987..1cc9e46 100644 --- a/src/store/manifestattachment/add.js +++ b/src/store/manifestattachment/add.js @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import sharp from 'sharp'; +import generateDocumentThumb from '../../util/generateDocumentThumb.js'; import { CREATE_FILE, CREATE_ATTACHMENT, @@ -142,19 +143,10 @@ export default function add(pool) { } if (!thumbavif) { - try { - const thumbResult = await sharp(content) - .resize(200, 200, { fit: 'inside', withoutEnlargement: true }) - .avif({ effort: 4 }) - .toBuffer({ resolveWithObject: true }); - thumbavif = thumbResult.data; - thumbx = thumbResult.info.width; - thumby = thumbResult.info.height; - } catch { - thumbavif = null; - thumbx = null; - thumby = null; - } + const generated = await generateDocumentThumb(content, mimetype); + thumbavif = generated.thumbavif; + thumbx = generated.thumbx; + thumby = generated.thumby; } const { companyId } = await access(googleAuthId); diff --git a/src/util/generateDocumentThumb.js b/src/util/generateDocumentThumb.js new file mode 100644 index 0000000..f3ac7ee --- /dev/null +++ b/src/util/generateDocumentThumb.js @@ -0,0 +1,85 @@ +import sharp from 'sharp'; +import { spawn } from 'node:child_process'; + +const THUMB_MAX = 200; + +/** + * Renders page 1 of a PDF into a PNG buffer using pdftocairo. + * @param {Buffer} pdfBuffer + * @returns {Promise} + */ +function renderPdfPageToPng(pdfBuffer) { + return new Promise((resolve, reject) => { + const proc = spawn('pdftocairo', ['-png', '-singlefile', '-', '-'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const chunks = []; + const errChunks = []; + + proc.stdout.on('data', (chunk) => chunks.push(chunk)); + proc.stderr.on('data', (chunk) => errChunks.push(chunk)); + + proc.on('error', (err) => { + reject(new Error(`Failed to start pdftocairo: ${err.message}`)); + }); + + proc.on('close', (code) => { + if (code !== 0) { + const stderr = Buffer.concat(errChunks).toString('utf8').trim(); + reject(new Error(`pdftocairo exited with code ${code}${stderr ? `: ${stderr}` : ''}`)); + } else { + resolve(Buffer.concat(chunks)); + } + }); + + proc.stdin.end(pdfBuffer); + }); +} + +/** + * Generates an AVIF thumbnail for a document (PDF or image). + * Returns { thumbavif, thumbx, thumby } or nulls if unsupported or rendering fails. + * + * @param {Buffer} content + * @param {string} mimetype + * @returns {Promise<{thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>} + */ +export default async function generateDocumentThumb(content, mimetype) { + if (!Buffer.isBuffer(content) || content.length === 0) { + return { thumbavif: null, thumbx: null, thumby: null }; + } + + const type = typeof mimetype === 'string' ? mimetype.trim().toLowerCase() : ''; + + let imageBuffer = null; + if (type === 'application/pdf') { + try { + imageBuffer = await renderPdfPageToPng(content); + } catch (err) { + console.warn('generateDocumentThumb: PDF render failed:', err.message); + return { thumbavif: null, thumbx: null, thumby: null }; + } + } else if (type.startsWith('image/')) { + imageBuffer = content; + } else { + // Other formats (e.g. text/plain) do not have an image thumbnail + return { thumbavif: null, thumbx: null, thumby: null }; + } + + try { + const thumbResult = await sharp(imageBuffer) + .resize(THUMB_MAX, THUMB_MAX, { fit: 'inside', withoutEnlargement: true }) + .avif({ effort: 4 }) + .toBuffer({ resolveWithObject: true }); + + return { + thumbavif: thumbResult.data, + thumbx: thumbResult.info.width, + thumby: thumbResult.info.height, + }; + } catch (err) { + console.warn('generateDocumentThumb: sharp thumbnail generation failed:', err.message); + return { thumbavif: null, thumbx: null, thumby: null }; + } +}