init f0ckm
This commit is contained in:
117
src/user_hall_image_handler.mjs
Normal file
117
src/user_hall_image_handler.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
// 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'];
|
||||
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', '-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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user