Files
f0ckm/src/user_hall_image_handler.mjs

118 lines
4.8 KiB
JavaScript

// user_hall_image_handler.mjs
// Handles multipart upload of custom images for user-owned halls.
// Mirrors the pattern used in hall_image_handler.mjs.
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";
const execFile = promisify(_execFile);
const HALL_CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
const HALL_CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
const lookupSession = async (req) => {
if (!req.cookies?.session) return null;
const users = await db`
SELECT "user".id, "user".user, "user".admin, "user".is_moderator,
"user_sessions".id as sess_id, "user_sessions".csrf_token
FROM "user_sessions"
LEFT JOIN "user" ON "user".id = "user_sessions".user_id
WHERE "user_sessions".session = ${lib.sha256(req.cookies.session)}
LIMIT 1
`;
return users.length > 0 ? users[0] : null;
};
const clearUserHallCache = async (userId, slug) => {
const { createHash } = await import('crypto');
for (const mode of [0, 1, 2]) {
const hash = createHash('md5').update(`uh_${userId}_${slug}_${mode}`).digest('hex');
await fs.unlink(path.join(HALL_CACHE_DIR, hash + '.webp')).catch(() => {});
}
};
// Called by the bypass middleware in index.mjs for POST /api/v2/me/halls/:slug/image
export const handleUserHallImageUpload = async (req, res, slug) => {
const session = await lookupSession(req);
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
// CSRF check
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
if (!token || token !== session.csrf_token) {
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
// Look up the hall (must be owned by this user)
const hall = (await db`
SELECT id FROM user_halls
WHERE user_id = ${session.id} AND slug = ${slug}
LIMIT 1
`)[0];
if (!hall) return sendJson(res, { success: false, msg: 'Hall not found' }, 404);
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
if (!boundaryMatch) return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
let boundary = boundaryMatch[1].trim();
if (boundary.startsWith('"') && boundary.endsWith('"')) boundary = boundary.slice(1, -1);
const body = await collectBody(req);
const parts = parseMultipart(body, boundary);
const file = parts.file;
if (!file?.data) return sendJson(res, { success: false, msg: 'No file provided' }, 400);
if (file.data.length > 10 * 1024 * 1024) return sendJson(res, { success: false, msg: 'File too large (10 MB max)' }, 400);
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
const fileMime = (file.contentType || '').toLowerCase().trim();
if (!allowedMimes.includes(fileMime))
return sendJson(res, { success: false, msg: 'Invalid file type: ' + fileMime }, 400);
await fs.mkdir(HALL_CUSTOM_DIR, { recursive: true });
const tmpPath = path.join(cfg.paths.tmp || '/tmp', `uhall_${session.id}_${slug}_tmp`);
const finalPath = path.join(HALL_CUSTOM_DIR, `u_${session.id}_${slug}.webp`);
await fs.writeFile(tmpPath, file.data);
// Verify with file magic
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
if (!['image/gif', 'image/jpeg', 'image/png', 'image/webp'].includes(actualMime.trim())) {
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'Invalid file type: ' + actualMime.trim() }, 400);
}
// Resize to 600x300 webp
try {
await execFile('magick', [tmpPath, '-coalesce', '-resize', '600x300^', '-gravity', 'center', '-background', 'none', '-extent', '600x300', '-quality', '85', finalPath]);
} catch (err) {
console.error('[USER_HALL_IMG] Magick error:', err.stderr || err.message);
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
}
await fs.unlink(tmpPath).catch(() => {});
await clearUserHallCache(session.id, slug);
await db`UPDATE user_halls SET custom_image = true WHERE id = ${hall.id}`;
console.log('[USER_HALL_IMG] Uploaded custom image for:', slug, 'by user:', session.id);
return sendJson(res, { success: true });
} catch (err) {
console.error('[USER_HALL_IMG] Upload error:', err);
return sendJson(res, { success: false, msg: err.message || 'Upload failed' }, 500);
}
};