Files
f0ckm/src/rethumb_handler.mjs
2026-05-04 04:24:18 +02:00

171 lines
6.6 KiB
JavaScript

import cfg from "./inc/config.mjs";
import path from "path";
import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import { execFile as _execFile } from "child_process";
import { promisify } from "util";
import queue from "./inc/queue.mjs";
const execFile = promisify(_execFile);
const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
export const handleRethumbUpload = async (req, res, itemId) => {
console.log('[RETHUMB HANDLER] Upload started for item:', itemId);
// Manual Session Lookup
let user = [];
if (req.cookies && req.cookies.session) {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
}
if (user.length === 0) {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
req.session = user[0];
// CSRF validation
if (req.session.csrf_token) {
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== req.session.csrf_token) {
console.warn(`[CSRF] Blocked rethumb upload for user ${req.session.user}. Invalid token.`);
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
}
// Verify Item exists and permissions
let item;
try {
const items = await db`select id, username, mime, active from items where id = ${+itemId} limit 1`;
if (items.length === 0) {
return sendJson(res, { success: false, msg: 'Item not found' }, 404);
}
item = items[0];
} catch (e) {
console.error('[RETHUMB HANDLER] Database error:', e);
return sendJson(res, { success: false, msg: 'Database error' }, 500);
}
// Permission check
if (!req.session.admin && !req.session.is_moderator && req.session.user !== item.username) {
return sendJson(res, { success: false, msg: 'Insufficient permissions' }, 403);
}
// Must be a Flash file (only flash files allow custom thumbnail reuploads currently)
if (item.mime !== 'application/x-shockwave-flash' && item.mime !== 'application/vnd.adobe.flash.movie') {
return sendJson(res, { success: false, msg: 'Only Flash uploads support custom thumbnails at this time' }, 400);
}
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
const body = await collectBody(req);
const parts = parseMultipart(body, boundaryMatch[1]);
const file = parts.file;
if (!file || !file.data) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
// Validate file size (5MB max)
const maxSize = 5 * 1024 * 1024;
if (file.data.length > maxSize) {
return sendJson(res, {
success: false,
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
}, 400);
}
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
let mime = file.contentType.toLowerCase();
if (!allowedMimes.includes(mime)) {
return sendJson(res, {
success: false,
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
}, 400);
}
// Save to tmp for verification
const uuid = (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
const tmpPath = path.join(cfg.paths.tmp, `rethumb_${uuid}_${itemId}`);
await fs.mkdir(cfg.paths.tmp, { recursive: true });
await fs.writeFile(tmpPath, file.data);
// Verify MIME
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
const allowedActualMimes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
if (!allowedActualMimes.includes(actualMime.trim())) {
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, {
success: false,
msg: `Invalid file type detected: ${actualMime.trim()}`
}, 400);
}
// Generate the thumbnail
const tDir = item.active ? cfg.paths.t : path.join(cfg.paths.pending, 't');
const finalPath = path.join(tDir, `${item.id}.webp`);
try {
await execFile('magick', [tmpPath, '-coalesce', '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', finalPath]);
// Check if item contains NSFW or NSFL tag
const tags = await db`
select tag_id from tags_assign
where item_id = ${+item.id}
and tag_id in (2, ${cfg.nsfl_tag_id || 3})
`;
if (tags.length > 0) {
// Generate blurred thumbnail
await queue.genBlurredThumbnail(item.id, !item.active);
}
} catch (err) {
console.error('[RETHUMB HANDLER] Magick error:', err);
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
}
await fs.unlink(tmpPath).catch(() => {});
try {
await db`SELECT pg_notify('rethumb', ${JSON.stringify({ item_id: item.id })})`;
} catch (err) {
console.error('[RETHUMB HANDLER] SSE notify error:', err);
}
console.log('[RETHUMB HANDLER] Custom thumbnail applied to item', item.id);
return sendJson(res, {
success: true,
msg: 'Thumbnail updated successfully'
}, 200);
} catch (err) {
if (err.code === 'BODY_TOO_LARGE') {
return sendJson(res, { success: false, msg: 'File too large (5 MB max)' }, 413);
}
console.error('[RETHUMB HANDLER ERROR]', err);
return sendJson(res, { success: false, msg: lib.logError(err, 'Thumbnail Upload failed') }, 500);
}
};