304 lines
14 KiB
JavaScript
304 lines
14 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 audit from "./inc/audit.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_IMG_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));
|
|
};
|
|
|
|
// Direct DB session lookup — same pattern as emoji_upload_handler.mjs
|
|
const lookupSession = async (req) => {
|
|
if (!req.cookies || !req.cookies.session) return null;
|
|
const users = await db`
|
|
select "user".id, "user".login, "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 clearHallCache = async (hallSlug) => {
|
|
const { createHash } = await import('crypto');
|
|
for (const mode of [0, 1, 2]) {
|
|
const hash = createHash('md5').update(hallSlug + '_' + mode).digest('hex');
|
|
await fs.unlink(path.join(HALL_CACHE_DIR, hash + '.webp')).catch(() => {});
|
|
}
|
|
};
|
|
|
|
// POST /api/v2/admin/halls/:slug/image — upload a custom image for a hall
|
|
export const handleHallImageUpload = async (req, res) => {
|
|
console.error('[BOOT] [HALL IMG] Upload started');
|
|
|
|
const session = await lookupSession(req);
|
|
if (!session || (!session.admin && !session.is_moderator)) {
|
|
console.error('[BOOT] [HALL IMG] Unauthorized');
|
|
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);
|
|
}
|
|
|
|
const hallSlug = req.params && req.params.slug;
|
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
|
|
|
const hall = await db`SELECT id, custom_image FROM halls WHERE slug = ${hallSlug} LIMIT 1`;
|
|
if (!hall.length) 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 || !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_IMG_DIR, { recursive: true });
|
|
|
|
const tmpPath = path.join(cfg.paths.tmp || '/tmp', 'hall_img_' + hallSlug + '_tmp');
|
|
const finalFilename = hallSlug + '.webp';
|
|
const finalPath = path.join(HALL_IMG_DIR, finalFilename);
|
|
|
|
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
|
|
// -coalesce is required for animated GIFs: it composites delta frames into full frames
|
|
// before resizing, preventing heavy artifacts. Output is animated WebP for GIFs.
|
|
try {
|
|
await execFile('magick', [tmpPath, '-coalesce', '-resize', '600x300^', '-gravity', 'center', '-background', 'none', '-extent', '600x300', '-quality', '85', finalPath]);
|
|
} catch (err) {
|
|
console.error('[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 db`UPDATE halls SET custom_image = ${finalFilename} WHERE slug = ${hallSlug}`;
|
|
await clearHallCache(hallSlug);
|
|
|
|
console.error('[BOOT] [HALL IMG] Uploaded custom image for:', hallSlug);
|
|
return sendJson(res, { success: true, msg: 'Image uploaded', file: finalFilename });
|
|
|
|
} catch (err) {
|
|
console.error('[HALL IMG] Upload error:', err);
|
|
return sendJson(res, { success: false, msg: err.message || 'Upload failed' }, 500);
|
|
}
|
|
};
|
|
|
|
// DELETE /api/v2/admin/halls/:slug/image — remove custom image
|
|
export const handleHallImageDelete = async (req, res) => {
|
|
const session = await lookupSession(req);
|
|
if (!session || (!session.admin && !session.is_moderator)) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
|
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);
|
|
|
|
const hallSlug = req.params && req.params.slug;
|
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
|
|
|
const hall = await db`SELECT id, custom_image FROM halls WHERE slug = ${hallSlug} LIMIT 1`;
|
|
if (!hall.length) return sendJson(res, { success: false, msg: 'Hall not found' }, 404);
|
|
|
|
// Always delete the canonical {slug}.webp from disk regardless of DB value
|
|
const filePath = path.join(HALL_IMG_DIR, hallSlug + '.webp');
|
|
try {
|
|
await fs.unlink(filePath);
|
|
console.error('[HALL IMG] Deleted file:', filePath);
|
|
} catch (e) {
|
|
if (e.code !== 'ENOENT') {
|
|
console.error('[HALL IMG] Failed to delete file:', filePath, e.message);
|
|
}
|
|
// ENOENT = already gone, that's fine
|
|
}
|
|
|
|
// Also delete whatever the DB says (in case it differs)
|
|
if (hall[0].custom_image && hall[0].custom_image !== hallSlug + '.webp') {
|
|
await fs.unlink(path.join(HALL_IMG_DIR, hall[0].custom_image)).catch((e) => {
|
|
if (e.code !== 'ENOENT') console.error('[HALL IMG] Failed to delete legacy file:', e.message);
|
|
});
|
|
}
|
|
|
|
await db`UPDATE halls SET custom_image = NULL WHERE slug = ${hallSlug}`;
|
|
await clearHallCache(hallSlug);
|
|
|
|
return sendJson(res, { success: true, msg: 'Custom image removed' });
|
|
};
|
|
|
|
// DELETE /api/v2/admin/halls/:slug — delete a hall entirely
|
|
export const handleHallDelete = async (req, res) => {
|
|
const session = await lookupSession(req);
|
|
if (!session || (!session.admin && !session.is_moderator)) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
|
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);
|
|
|
|
const hallSlug = req.params && req.params.slug;
|
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
|
|
|
await db`DELETE FROM halls_assign WHERE hall_id = (SELECT id FROM halls WHERE slug = ${hallSlug})`.catch(() => {});
|
|
await db`DELETE FROM halls WHERE slug = ${hallSlug}`;
|
|
|
|
await fs.unlink(path.join(HALL_IMG_DIR, hallSlug + '.webp')).catch(() => {});
|
|
await clearHallCache(hallSlug);
|
|
|
|
console.error('[BOOT] [HALL] Deleted hall:', hallSlug);
|
|
return sendJson(res, { success: true, msg: 'Hall deleted' });
|
|
};
|
|
|
|
// PATCH /api/v2/admin/halls/:slug — update name/description/slug
|
|
export const handleHallUpdate = async (req, res) => {
|
|
const session = await lookupSession(req);
|
|
if (!session || (!session.admin && !session.is_moderator)) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
|
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);
|
|
|
|
const hallSlug = req.params && req.params.slug;
|
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing slug' }, 400);
|
|
|
|
// For PATCH requests, parse body manually since this is a bypass handler
|
|
let body = {};
|
|
try {
|
|
const raw = await collectBody(req);
|
|
body = JSON.parse(raw.toString('utf8'));
|
|
} catch (e) {
|
|
body = req.post || {};
|
|
}
|
|
|
|
const { name, description } = body;
|
|
const rawRating = body.rating;
|
|
const rating = ['sfw', 'nsfw', 'nsfl'].includes(rawRating) ? rawRating : null;
|
|
const rawSlug = body.slug;
|
|
|
|
// Handle slug rename
|
|
if (rawSlug !== undefined) {
|
|
const newSlug = rawSlug.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
if (!newSlug) return sendJson(res, { success: false, msg: 'Slug cannot be empty' }, 400);
|
|
|
|
if (newSlug !== hallSlug) {
|
|
// Check for conflict with another hall
|
|
const conflict = await db`SELECT id FROM halls WHERE slug = ${newSlug} LIMIT 1`;
|
|
if (conflict.length > 0) {
|
|
return sendJson(res, { success: false, msg: 'A hall with that slug already exists' }, 409);
|
|
}
|
|
|
|
// Rename custom image file on disk
|
|
const oldFile = path.join(HALL_IMG_DIR, hallSlug + '.webp');
|
|
const newFile = path.join(HALL_IMG_DIR, newSlug + '.webp');
|
|
try {
|
|
await fs.rename(oldFile, newFile);
|
|
console.error('[HALL IMG] Renamed image:', hallSlug + '.webp', '->', newSlug + '.webp');
|
|
} catch (e) {
|
|
if (e.code !== 'ENOENT') console.error('[HALL IMG] Failed to rename image:', e.message);
|
|
}
|
|
|
|
// Update slug in DB and fix custom_image filename if set
|
|
await db`
|
|
UPDATE halls
|
|
SET slug = ${newSlug},
|
|
custom_image = CASE WHEN custom_image IS NOT NULL THEN ${newSlug + '.webp'} ELSE NULL END
|
|
WHERE slug = ${hallSlug}
|
|
`;
|
|
|
|
await clearHallCache(hallSlug);
|
|
await clearHallCache(newSlug);
|
|
|
|
// Update name/description/rating under the new slug
|
|
if (name !== undefined && name.trim()) {
|
|
await db`UPDATE halls SET name = ${name.trim()} WHERE slug = ${newSlug}`;
|
|
}
|
|
if (description !== undefined) {
|
|
await db`UPDATE halls SET description = ${description} WHERE slug = ${newSlug}`;
|
|
}
|
|
if (rating) {
|
|
await db`UPDATE halls SET rating = ${rating} WHERE slug = ${newSlug}`;
|
|
}
|
|
|
|
await audit.log(session.id, 'rename_hall', 'hall', newSlug, { old_slug: hallSlug, new_slug: newSlug, name: name?.trim(), description });
|
|
return sendJson(res, { success: true, msg: 'Hall updated', newSlug });
|
|
}
|
|
}
|
|
|
|
// No slug change — just update name/description/rating
|
|
if (name !== undefined && name.trim()) {
|
|
await db`UPDATE halls SET name = ${name.trim()} WHERE slug = ${hallSlug}`;
|
|
}
|
|
if (description !== undefined) {
|
|
await db`UPDATE halls SET description = ${description} WHERE slug = ${hallSlug}`;
|
|
}
|
|
if (rating) {
|
|
await db`UPDATE halls SET rating = ${rating} WHERE slug = ${hallSlug}`;
|
|
}
|
|
|
|
await audit.log(session.id, 'update_hall', 'hall', hallSlug, { name: name?.trim(), description });
|
|
return sendJson(res, { success: true, msg: 'Hall updated' });
|
|
};
|
|
|
|
// POST /api/v2/admin/halls — create a new hall
|
|
export const handleHallCreate = async (req, res) => {
|
|
// 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);
|
|
}
|
|
|
|
let body = {};
|
|
try {
|
|
const raw = await collectBody(req);
|
|
body = JSON.parse(raw.toString('utf8'));
|
|
} catch (e) {
|
|
return sendJson(res, { success: false, msg: 'Invalid JSON body' }, 400);
|
|
}
|
|
|
|
const name = (body.name || '').trim();
|
|
const slug = (body.slug || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
const rawRating = body.rating;
|
|
const rating = ['sfw', 'nsfw', 'nsfl'].includes(rawRating) ? rawRating : 'sfw';
|
|
|
|
if (!name) return sendJson(res, { success: false, msg: 'Name is required' }, 400);
|
|
if (!slug) return sendJson(res, { success: false, msg: 'Could not derive a valid slug from the name' }, 400);
|
|
|
|
// Check for duplicate slug
|
|
const existing = await db`SELECT id FROM halls WHERE slug = ${slug} LIMIT 1`;
|
|
if (existing.length > 0) return sendJson(res, { success: false, msg: 'A hall with that slug already exists' }, 409);
|
|
|
|
await db`INSERT INTO halls (name, slug, description, rating) VALUES (${name}, ${slug}, '', ${rating})`;
|
|
|
|
await audit.log(session.id, 'create_hall', 'hall', slug, { name, slug });
|
|
console.error('[HALL] Created hall:', slug, '-', name);
|
|
return sendJson(res, { success: true, msg: 'Hall created', slug });
|
|
};
|