u
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { LIST_BY_MANIFEST } from '../../sql/manifestattachment.js';
|
import { LIST_BY_MANIFEST } from '../../sql/manifestattachment.js';
|
||||||
import request from '../request.js';
|
import request from '../request.js';
|
||||||
|
import generateDocumentThumb from '../../util/generateDocumentThumb.js';
|
||||||
import { parseMeta } from '../manifestattachment/listByManifestId.js';
|
import { parseMeta } from '../manifestattachment/listByManifestId.js';
|
||||||
|
|
||||||
const ALLOWED_MIMETYPES = new Set([
|
const ALLOWED_MIMETYPES = new Set([
|
||||||
@@ -21,7 +21,9 @@ export function shapeAttachmentMeta(row) {
|
|||||||
size: Number(row.size),
|
size: Number(row.size),
|
||||||
created: row.created,
|
created: row.created,
|
||||||
modified: row.modified,
|
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,
|
thumbx: row.thumbx ?? null,
|
||||||
thumby: row.thumby ?? null,
|
thumby: row.thumby ?? null,
|
||||||
meta: parseMeta(row.meta),
|
meta: parseMeta(row.meta),
|
||||||
@@ -88,6 +90,8 @@ function normalizeAttachment(raw, index) {
|
|||||||
meta = {};
|
meta = {};
|
||||||
for (const [key, value] of Object.entries(raw.meta)) {
|
for (const [key, value] of Object.entries(raw.meta)) {
|
||||||
if (value === undefined || value === null) continue;
|
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);
|
meta[key] = String(value);
|
||||||
}
|
}
|
||||||
if (Object.keys(meta).length === 0) {
|
if (Object.keys(meta).length === 0) {
|
||||||
@@ -132,6 +136,9 @@ export async function insertAttachments(pool, tx, sql, manifestId, files) {
|
|||||||
const sort = a.sort ?? nextSort;
|
const sort = a.sort ?? nextSort;
|
||||||
nextSort = Math.max(nextSort, sort) + 1;
|
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)
|
const fileRes = await request(pool, tx)
|
||||||
.input('hash', a.hash)
|
.input('hash', a.hash)
|
||||||
.input('mimetype', a.mimetype)
|
.input('mimetype', a.mimetype)
|
||||||
@@ -140,9 +147,9 @@ export async function insertAttachments(pool, tx, sql, manifestId, files) {
|
|||||||
.input('created', a.created)
|
.input('created', a.created)
|
||||||
.input('modified', a.modified)
|
.input('modified', a.modified)
|
||||||
.input('content', a.content)
|
.input('content', a.content)
|
||||||
.input('thumbavif', null)
|
.input('thumbavif', thumbavif)
|
||||||
.input('thumbx', null)
|
.input('thumbx', thumbx)
|
||||||
.input('thumby', null)
|
.input('thumby', thumby)
|
||||||
.input('meta', a.meta === null ? null : JSON.stringify(a.meta))
|
.input('meta', a.meta === null ? null : JSON.stringify(a.meta))
|
||||||
.query(sql.CREATE_FILE);
|
.query(sql.CREATE_FILE);
|
||||||
|
|
||||||
@@ -164,6 +171,11 @@ export async function insertAttachments(pool, tx, sql, manifestId, files) {
|
|||||||
size: a.size,
|
size: a.size,
|
||||||
created: a.created,
|
created: a.created,
|
||||||
modified: a.modified,
|
modified: a.modified,
|
||||||
|
thumbavif: thumbavif
|
||||||
|
? (Buffer.isBuffer(thumbavif) ? thumbavif.toString('base64') : thumbavif)
|
||||||
|
: null,
|
||||||
|
thumbx: thumbx ?? null,
|
||||||
|
thumby: thumby ?? null,
|
||||||
meta: a.meta,
|
meta: a.meta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import generateDocumentThumb from '../../util/generateDocumentThumb.js';
|
||||||
import {
|
import {
|
||||||
CREATE_FILE,
|
CREATE_FILE,
|
||||||
CREATE_ATTACHMENT,
|
CREATE_ATTACHMENT,
|
||||||
@@ -142,19 +143,10 @@ export default function add(pool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!thumbavif) {
|
if (!thumbavif) {
|
||||||
try {
|
const generated = await generateDocumentThumb(content, mimetype);
|
||||||
const thumbResult = await sharp(content)
|
thumbavif = generated.thumbavif;
|
||||||
.resize(200, 200, { fit: 'inside', withoutEnlargement: true })
|
thumbx = generated.thumbx;
|
||||||
.avif({ effort: 4 })
|
thumby = generated.thumby;
|
||||||
.toBuffer({ resolveWithObject: true });
|
|
||||||
thumbavif = thumbResult.data;
|
|
||||||
thumbx = thumbResult.info.width;
|
|
||||||
thumby = thumbResult.info.height;
|
|
||||||
} catch {
|
|
||||||
thumbavif = null;
|
|
||||||
thumbx = null;
|
|
||||||
thumby = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { companyId } = await access(googleAuthId);
|
const { companyId } = await access(googleAuthId);
|
||||||
|
|||||||
85
src/util/generateDocumentThumb.js
Normal file
85
src/util/generateDocumentThumb.js
Normal file
@@ -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<Buffer>}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user