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:
@@ -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({
|
||||||
|
|||||||
+46
-27
@@ -131,33 +131,7 @@ 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
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF: extract page info, create pdf_pages rows, queue thumbnail jobs
|
|
||||||
if (mediaType === 'pdf') {
|
if (mediaType === 'pdf') {
|
||||||
const { tmpPath, cleanup } = bufferToTempFile(buffer, '.pdf');
|
const { tmpPath, cleanup } = bufferToTempFile(buffer, '.pdf');
|
||||||
try {
|
try {
|
||||||
@@ -166,6 +140,25 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
|||||||
return res.status(400).json({ error: 'PDF exceeds 500 page limit' });
|
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 image = createImage({
|
||||||
|
id: imageId,
|
||||||
|
boardId: board.id,
|
||||||
|
filename: originalname,
|
||||||
|
mimeType: mimetype,
|
||||||
|
fileSize: size,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
minioPath,
|
||||||
|
publicUrl,
|
||||||
|
uploadedBy: req.user.id,
|
||||||
|
assetKey,
|
||||||
|
mediaType,
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user