fix: early PDF validation, type-aware errors, exact job dedup

- Move PDF page count check before MinIO upload and DB record creation
  to prevent orphaned objects when >500 page PDFs are rejected
- Use job type label (PDF page / Video) in media-worker error messages
  instead of hardcoded "Video processing failed"
- Replace LIKE-based idempotency check with exact JSON match to prevent
  page 1 matching page 11/12/etc substring collisions
This commit is contained in:
Hiren Kangad
2026-03-14 11:16:58 +05:30
parent 4b68e337cf
commit 247145f545
3 changed files with 55 additions and 35 deletions
+4 -4
View File
@@ -67,8 +67,8 @@ router.post('/:boardId/pdf-pages', async (req, res) => {
// Check for existing pending/processing hires job for this page // Check for existing pending/processing hires job for this page
const { db } = require('../db'); const { db } = require('../db');
const existingJob = db.prepare( const existingJob = db.prepare(
`SELECT id FROM media_jobs WHERE image_id = ? AND type = 'pdf-hires' AND result_json LIKE ? AND status IN ('queued', 'processing')` `SELECT id FROM media_jobs WHERE image_id = ? AND type = 'pdf-hires' AND result_json = ? AND status IN ('queued', 'processing')`
).get(imageId, `%"pageNumber":${pageNum}%`); ).get(imageId, JSON.stringify({ pageNumber: pageNum }));
if (!existingJob) { if (!existingJob) {
createMediaJob({ createMediaJob({
@@ -126,8 +126,8 @@ router.post('/:boardId/pdf-thumbnails', async (req, res) => {
// Skip if job already pending // Skip if job already pending
const { db } = require('../db'); const { db } = require('../db');
const existingJob = db.prepare( const existingJob = db.prepare(
`SELECT id FROM media_jobs WHERE image_id = ? AND type = 'pdf-thumbnail' AND result_json LIKE ? AND status IN ('queued', 'processing')` `SELECT id FROM media_jobs WHERE image_id = ? AND type = 'pdf-thumbnail' AND result_json = ? AND status IN ('queued', 'processing')`
).get(imageId, `%"pageNumber":${pageNum}%`); ).get(imageId, JSON.stringify({ pageNumber: pageNum }));
if (!existingJob) { if (!existingJob) {
createMediaJob({ createMediaJob({
+37 -18
View File
@@ -131,10 +131,19 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
const { buffer, originalname, mimetype, size } = req.file; const { buffer, originalname, mimetype, size } = req.file;
const mediaType = classifyMedia(mimetype); const mediaType = classifyMedia(mimetype);
const { assetKey, minioPath, width, height, isVideo } = await uploadMedia(board.id, imageId, buffer, mimetype); // PDF: validate page count BEFORE uploading to MinIO or creating DB records
if (mediaType === 'pdf') {
const { tmpPath, cleanup } = bufferToTempFile(buffer, '.pdf');
try {
const info = await pdfInfo(tmpPath);
if (info.pageCount > 500) {
return res.status(400).json({ error: 'PDF exceeds 500 page limit' });
}
// Validation passed — now upload and create records
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimetype);
const publicUrl = getImageUrl(minioPath); const publicUrl = getImageUrl(minioPath);
// Save image record first (media_jobs FK references images)
const image = createImage({ const image = createImage({
id: imageId, id: imageId,
boardId: board.id, boardId: board.id,
@@ -150,22 +159,6 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
mediaType, mediaType,
}); });
// Enqueue background processing after image record exists
let jobId = null;
if (isVideo) {
jobId = uuidv4();
createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' });
}
// PDF: extract page info, create pdf_pages rows, queue thumbnail jobs
if (mediaType === 'pdf') {
const { tmpPath, cleanup } = bufferToTempFile(buffer, '.pdf');
try {
const info = await pdfInfo(tmpPath);
if (info.pageCount > 500) {
return res.status(400).json({ error: 'PDF exceeds 500 page limit' });
}
updateImagePageCount(imageId, info.pageCount); updateImagePageCount(imageId, info.pageCount);
// Create pdf_pages rows for all pages // Create pdf_pages rows for all pages
@@ -214,6 +207,32 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
} }
} }
const { assetKey, minioPath, width, height, isVideo } = await uploadMedia(board.id, imageId, buffer, mimetype);
const publicUrl = getImageUrl(minioPath);
// Save image record first (media_jobs FK references images)
const image = createImage({
id: imageId,
boardId: board.id,
filename: originalname,
mimeType: mimetype,
fileSize: size,
width,
height,
minioPath,
publicUrl,
uploadedBy: req.user.id,
assetKey,
mediaType,
});
// Enqueue background processing after image record exists
let jobId = null;
if (isVideo) {
jobId = uuidv4();
createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' });
}
return res.status(201).json({ return res.status(201).json({
id: image.id, id: image.id,
url: publicUrl, url: publicUrl,
+5 -4
View File
@@ -144,13 +144,14 @@ async function processJob(job) {
console.error('[media-worker] Job %s failed:', jobId, err.message); console.error('[media-worker] Job %s failed:', jobId, err.message);
// Classify error for user-facing message // Classify error for user-facing message
let userError = 'Video processing failed'; const typeLabel = job.type.startsWith('pdf-') ? 'PDF page' : 'Video';
let userError = `${typeLabel} processing failed`;
if (err.killed || err.signal === 'SIGTERM') { if (err.killed || err.signal === 'SIGTERM') {
userError = 'Video processing timed out — file may be too large or corrupt'; userError = `${typeLabel} processing timed out — file may be too large or corrupt`;
} else if (err.message?.includes('ENOMEM') || err.message?.includes('Cannot allocate')) { } else if (err.message?.includes('ENOMEM') || err.message?.includes('Cannot allocate')) {
userError = 'Out of memory — video file is too large to process'; userError = `Out of memory — file is too large to process`;
} else if (err.message?.includes('Invalid data')) { } else if (err.message?.includes('Invalid data')) {
userError = 'Invalid or corrupt video file'; userError = `Invalid or corrupt ${typeLabel.toLowerCase()} file`;
} }
const attempts = (job.attempts || 0) + 1; const attempts = (job.attempts || 0) + 1;