import db from "../sql.mjs"; import lib from "../lib.mjs"; import audit from "../audit.mjs"; import { promises as fs } from "fs"; import cfg from "../config.mjs"; import fetch from "flumm-fetch"; import https from "https"; import path from "path"; import { getManualApproval } from "../settings.mjs"; import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs"; import { setMotd } from "../motd.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; export default (router, tpl) => { // Moderator Dashboard router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => { const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c; res.reply({ body: tpl.render("mod", { session: req.session, pendingCount: parseInt(pendingCount), manualApproval: getManualApproval(), tmp: null }, req) }); }); // Moderator Reports View router.get(/^\/mod\/reports\/?$/, lib.modAuth, async (req, res) => { res.reply({ body: tpl.render("mod_reports", { session: req.session, tmp: null }, req) }); }); // Approval Queue (View only — GET is safe, no state change) router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => { // View Queue const page = +req.url.qs.page || 1; const limit = 20; // Fetch Pending (not deleted) const pending = await db` select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags from "items" i left join "tags_assign" ta on ta.item_id = i.id left join "tags" t on t.id = ta.tag_id where i.active = false and i.is_deleted = false group by i.id order by i.id desc limit ${limit} offset ${(page - 1) * limit} `; // Fetch Trash (deleted) const trash = await db` select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags, (select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason from "items" i left join "tags_assign" ta on ta.item_id = i.id left join "tags" t on t.id = ta.tag_id where i.active = false and i.is_deleted = true and i.is_purged = false group by i.id order by i.id desc limit 20 `; const processItems = (items) => { return items.map(p => { const tags = (p.tags || []) .filter(t => t.tag !== null) .map(t => { let badge = "badge-light"; if (t.tag.startsWith(">")) badge = "badge-greentext badge-light"; else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light"; else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light"; else if (t.normalized === "german") badge = "badge-german badge-light"; else if (t.normalized === "dutch") badge = "badge-dutch badge-light"; else if (t.normalized === "sfw") badge = "badge-success"; else if (t.normalized === "nsfw") badge = "badge-danger"; return { ...t, badge }; }); return { ...p, tags }; }); }; res.reply({ body: tpl.render('mod/approve', { pending: processItems(pending), trash: processItems(trash), page, stats: { total: pending.length + trash.length }, session: req.session, tmp: null }, req) }); }); // F-005 Security: Approve action — POST with CSRF protection router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => { const id = +(req.post?.id || 0); if (!id) { const body = JSON.stringify({ success: false, msg: 'No ID provided' }); return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } const f0ck = await db` select i.dest, i.mime, i.username, i.id, ta.tag_id from "items" i left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2) where i.id = ${id} and i.active = false limit 1 `; if (f0ck.length === 0) { const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` }); return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } // Fetch uploader details for audit log let uploaderInfo = {}; try { const uploader = await db`select id, "user" as username from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`; if (uploader.length > 0) { uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username }; } } catch (err) { } // ACTION: Approve // We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true. // This prevents duplicate webhooks from double-clicks or race conditions. const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`; if (result.count === 1) { await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo }); // Notify User (WebSocket/Internal) try { const uploader = await db`select id from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`; if (uploader.length > 0) { await db` INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${uploader[0].id}, 'approve', 0, ${id}) `; } } catch (err) { console.error('[MOD APPROVE] Failed to notify user:', err); } // Push to Discord Webhook (Direct) try { const discordClient = cfg.clients.find(c => c.type === 'discord'); if (discordClient && discordClient.webhook_url) { const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`; const payload = JSON.stringify({ content: message }); const url = new URL(discordClient.webhook_url); const options = { hostname: url.hostname, path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } }; const reqDiscord = https.request(options, (resDiscord) => { if (resDiscord.statusCode >= 400) { console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`); } }); reqDiscord.on('error', (err) => { console.error('[MOD APPROVE] Webhook failed:', err); }); reqDiscord.write(payload); reqDiscord.end(); } } catch (err) { console.error('[MOD APPROVE] Discord Webhook error:', err); } // Push to Matrix Channel try { const matrixCfg = cfg.clients.find(c => c.type === 'matrix'); if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) { const clients = await Promise.all(router.self.bot.clients); const matrixWrapper = clients.find(c => c.type === 'matrix'); if (matrixWrapper?.client) { const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`; await matrixWrapper.client.send(matrixCfg.notification_channel_id, message); console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`); } } } catch (err) { console.error('[MOD APPROVE] Matrix notification error:', err); } // Broadcast new_item event for live grid updates try { await db`SELECT pg_notify('new_item', ${JSON.stringify({ id: id, dest: f0ck[0].dest, mime: f0ck[0].mime, username: f0ck[0].username, tag_id: f0ck[0].tag_id, is_oc: !!f0ck[0].is_oc })})`; } catch (err) { console.error('[MOD APPROVE] new_item notify failed:', err); } } // Move files to public location const movePaths = [ { b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) }, { b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) } ]; const isYouTube = f0ck[0].mime === 'video/youtube'; for (const p of movePaths) { try { if (isYouTube) { await fs.access(p.t); } else { await fs.access(p.b); } console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`); const moveSafe = async (src, dst) => { try { const lstat = await fs.lstat(src); if (lstat.isSymbolicLink()) { const target = await fs.readlink(src); const absTarget = path.resolve(path.dirname(src), target); const relTarget = path.relative(path.dirname(dst), absTarget); await fs.symlink(relTarget, dst); await fs.unlink(src).catch(() => {}); } else { await fs.copyFile(src, dst); await fs.unlink(src).catch(() => {}); } } catch (e) { if (e.code !== 'ENOENT') { console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message); } } }; const bDst = path.join(cfg.paths.b, f0ck[0].dest); const tDst = path.join(cfg.paths.t, `${id}.webp`); const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`); const caDst = path.join(cfg.paths.ca, `${id}.webp`); if (!isYouTube) { await moveSafe(p.b, bDst); } await moveSafe(p.t, tDst); const blurSrc = p.t.replace('.webp', '_blur.webp'); await moveSafe(blurSrc, blurDst); if (f0ck[0].mime.startsWith('audio')) { await moveSafe(p.ca, caDst); } break; } catch (e) { } } if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.writeHead(302, { "Location": `/${id}` }).end(); }); // F-005 Security: Deny action — POST with CSRF protection router.post(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => { const id = +(req.post?.id || 0); if (!id) { const body = JSON.stringify({ success: false, msg: 'No ID provided' }); return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${id} limit 1`; if (f0ck.length > 0) { const item = f0ck[0]; if (item.is_deleted) { // PURGE LOGIC (Strict Admin) if (!req.session.admin) { return res.reply({ success: false, msg: "Only admins can purge items permanently." }); } // Delete files — respect symlink ownership await safeDeleteMediaFile(item.dest, id); await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { }); await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { }); if (item.mime?.startsWith('audio')) { await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { }); } // DB Flag instead of delete await db`update "items" set is_purged = true where id = ${id}`; // Delete comments permanently on purge await db`delete from comments where item_id = ${id}`; // Fetch uploader details for audit log let uploaderInfo = {}; try { const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`; if (uploader.length > 0) { uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username }; } } catch (err) { } await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, ...uploaderInfo }); if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { const body = JSON.stringify({ success: true, action: "purge", msg: "Item purged" }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.reply({ success: true, action: "purge" }); } else { // DENY LOGIC (Move to trash) const movePaths = [ { b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) }, { b: path.join(cfg.paths.b, item.dest), t: path.join(cfg.paths.t, `${id}.webp`), ca: path.join(cfg.paths.ca, `${id}.webp`) } ]; for (const p of movePaths) { try { await fs.access(p.b); // Use safe move that handles symlink ownership await moveToDeleted(item.dest, id); await fs.copyFile(p.t, path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { }); await fs.unlink(p.t).catch(() => { }); if (item.mime?.startsWith('audio')) { await fs.copyFile(p.ca, path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { }); await fs.unlink(p.ca).catch(() => { }); } break; } catch (e) { } } const reason = req.post?.reason || "Denied by moderator"; await db`update "items" set is_deleted = true, active = false where id = ${id}`; // Fetch uploader details for audit log and notification let uploaderId = null; let uploaderInfo = {}; try { const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`; if (uploader.length > 0) { uploaderId = uploader[0].id; uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username }; // Send Notification to User await db` INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${uploaderId}, 'deny', 0, ${id}, ${db.json({ reason })}) `; } } catch (err) { console.error('[MOD DENY] Failed to notify uploader:', err); } await audit.log(req.session.id, 'deny_item', 'item', id, { filename: item.dest, reason, ...uploaderInfo }); if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { const body = JSON.stringify({ success: true, action: "deny", msg: "Item denied" }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.reply({ success: true, action: "deny" }); } } return res.reply({ success: false, msg: "Item not found" }); }); // Audit Log View router.get(/^\/mod\/audit\/?/, lib.modAuth, async (req, res) => { const page = +(req.url.qs?.page || 1); const limit = 50; const offset = (page - 1) * limit; const logs = await db` SELECT al.*, u.user as username FROM audit_log al LEFT JOIN "user" u ON al.user_id = u.id ORDER BY al.created_at DESC LIMIT ${limit} OFFSET ${offset} `; const totalResult = await db`SELECT count(*) as c FROM audit_log`; const total = totalResult[0].c; const pages = Math.ceil(total / limit); const processLogEntry = (l) => { const entry = { ...l }; let details = l.details; if (typeof details === 'string') { try { details = JSON.parse(details); } catch (e) { details = {}; } } entry.created_at_fmt = new Date(l.created_at).toLocaleString(); // Also keep standard created_at for client side entry.created_at = entry.created_at_fmt; entry.reason = details ? details.reason : null; entry.old_content = details ? details.old_content : null; entry.new_content = details ? details.new_content : null; entry.item_id = details ? details.item_id : null; if (entry.action === 'toggle_tag' && details) { if (details.action === 'added') { entry.new_content = details.tag; } else if (details.from) { entry.old_content = details.from; entry.new_content = details.tag; } } if (details && details.uploader_name) { entry.uploader_info = `Uploader: ${details.uploader_name} (ID: ${details.uploader_id || '?'})`; } let otherDetails = {}; if (details && typeof details === 'object') { for (const [key, val] of Object.entries(details)) { const isStandard = ['reason', 'old_content', 'new_content', 'item_id', 'uploader_name', 'uploader_id'].includes(key); const isToggle = entry.action === 'toggle_tag' && ['tag', 'from', 'action'].includes(key); if (!isStandard && !isToggle) { otherDetails[key] = val; } } } entry.details_json = Object.keys(otherDetails).length > 0 ? JSON.stringify(otherDetails) : null; delete entry.details; return entry; }; if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const processed = logs.map(processLogEntry); const body = JSON.stringify({ success: true, logs: processed, page, pages, hasMore: page < pages }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } const processedLogs = logs.map(processLogEntry); res.reply({ body: tpl.render("mod/audit", { session: req.session, logs: processedLogs, page, pages, tmp: null }, req) }); }); // Bulk Deny (POST) router.post(/^\/mod\/deny-multi\/?/, lib.modAuth, async (req, res) => { try { const ids = req.post.ids; if (!Array.isArray(ids)) throw new Error('ids must be an array'); let count = 0; for (const id of ids) { const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${+id} limit 1`; if (f0ck.length > 0) { const item = f0ck[0]; if (item.is_deleted) { // Purge (Strict Admin) if (!req.session.admin) continue; // Purge — respect symlink ownership await safeDeleteMediaFile(item.dest, +id); await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { }); if (item.mime?.startsWith('audio')) { await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { }); } await db`update "items" set is_purged = true where id = ${+id}`; // Delete comments permanently on purge await db`delete from comments where item_id = ${+id}`; // Fetch uploader details for audit log let uploaderInfo = {}; try { const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`; if (uploader.length > 0) { uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username }; } } catch (err) { } await audit.log(req.session.id, 'purge_item_multi', 'item', +id, { filename: item.dest, ...uploaderInfo }); } else { // Deny (Move to trash) // Deny — safe move respecting symlink ownership await moveToDeleted(item.dest, +id); await fs.copyFile(path.join(cfg.paths.t, `${item.id}.webp`), path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { }); if (item.mime?.startsWith('audio')) { await fs.copyFile(path.join(cfg.paths.ca, `${item.id}.webp`), path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { }); } await db`update "items" set is_deleted = true, active = false where id = ${+id}`; // Fetch uploader details for audit log let uploaderInfo = {}; try { const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`; if (uploader.length > 0) { uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username }; } } catch (err) { } await audit.log(req.session.id, 'deny_item_multi', 'item', +id, { filename: item.dest, ...uploaderInfo }); } count++; } } return res.reply({ success: true, count }); } catch (err) { return res.reply({ success: false, msg: lib.logError(err) }, 500); } }); // Serve pending files (Stream with Range support) // Supports /mod/pending/b/filename.ext (Binaries) // Supports /mod/pending/t/id.webp (Thumbnails) router.get(/^\/mod\/pending\/(?[btca])\/(?.+)/, lib.modAuth, async (req, res) => { const { type } = req.params; // F-003 Security: Sanitize file parameter to prevent path traversal const file = path.basename(req.params.file); const baseDir = path.resolve(cfg.paths.pending, type); const filePath = path.resolve(baseDir, file); if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) { return res.writeHead(403).end('Forbidden'); } try { const stats = await fs.stat(filePath); const fileSize = stats.size; const range = req.headers.range; const ext = file.split('.').pop(); const mimeType = { 'mp4': 'video/mp4', 'webm': 'video/webm', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'pdf': 'application/pdf' }[ext] || 'application/octet-stream'; if (range) { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunksize = (end - start) + 1; const fileStream = (await import('fs')).createReadStream(filePath, { start, end }); res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': mimeType, }); fileStream.pipe(res); } else { res.writeHead(200, { 'Content-Length': fileSize, 'Content-Type': mimeType, }); (await import('fs')).createReadStream(filePath).pipe(res); } } catch (err) { if (err.code !== 'ENOENT') console.error(err); res.writeHead(404).end('File not found'); } }); // Serve deleted files (Stream with Range support) // Supports /mod/deleted/b/filename.ext (Binaries) // Supports /mod/deleted/t/id.webp (Thumbnails) router.get(/^\/mod\/deleted\/(?[bt])\/(?.+)/, lib.modAuth, async (req, res) => { // F-003 Security: Sanitize file parameter to prevent path traversal const file = path.basename(decodeURIComponent(req.params.file)); const type = req.params.type; // 'b' or 't' console.log(`[MOD_STREAM] Request: type=${type}, file=${file}, range=${req.headers.range || 'none'}`); const baseDir = path.resolve(cfg.paths.deleted, type); const filePath = path.resolve(baseDir, file); if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) { return res.writeHead(403).end('Forbidden'); } try { const stat = await fs.stat(filePath); const fileSize = stat.size; const range = req.headers.range; const ext = file.split('.').pop(); const mimeType = { 'mp4': 'video/mp4', 'webm': 'video/webm', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'pdf': 'application/pdf' }[ext] || 'application/octet-stream'; if (range) { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunksize = (end - start) + 1; const fileStream = (await import('fs')).createReadStream(filePath, { start, end }); res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': mimeType, }); fileStream.pipe(res); } else { res.writeHead(200, { 'Content-Length': fileSize, 'Content-Type': mimeType, }); (await import('fs')).createReadStream(filePath).pipe(res); } } catch (err) { if (err.code !== 'ENOENT') console.error(err); res.writeHead(404).end('File not found'); } }); // Purge Trash (POST) - Strict Admin router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => { try { // lib.auth already ensures session.admin const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`; let count = 0; for (const item of trash) { try { await safeDeleteMediaFile(item.dest, item.id); await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { }); if (item.mime?.startsWith('audio')) { await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { }); } await db`update "items" set is_purged = true where id = ${item.id}`; // Delete comments permanently on purge await db`delete from comments where item_id = ${item.id}`; count++; } catch (e) { } } await audit.log(req.session.id, 'purge_trash', 'system', 0, { count }); const body = JSON.stringify({ success: true, count }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } catch (err) { const body = JSON.stringify({ success: false, msg: 'Purge failed' }); return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } }); // MOTD Routes router.get(/^\/mod\/motd\/?$/, lib.modAuth, async (req, res) => { const settings = await db`SELECT value FROM site_settings WHERE key = 'motd' LIMIT 1`; const motd = settings.length > 0 ? settings[0].value : ''; res.reply({ body: tpl.render("mod/motd", { session: req.session, motd: motd, tmp: null }, req) }); }); router.post(/^\/mod\/motd\/?$/, lib.modAuth, async (req, res) => { try { let motd = (req.post?.motd ?? ''); if (motd.trim().length > 0) { // Append .t name suffix (display_name if set, otherwise username) const name = req.session.display_name || req.session.user; const suffix = ` t. ${name}`; if (!motd.endsWith(suffix)) { motd = motd.trim() + suffix; } } await db`INSERT INTO site_settings (key, value) VALUES ('motd', ${motd}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setMotd(motd); // Log it in audit await audit.log(req.session.id, 'update_motd', 'system', 0, { motd }); try { await db`SELECT pg_notify('motd', ${motd ? motd.substring(0, 7000) : ''})`; } catch (e) { console.error('[MOD MOTD] Notify failed:', e.message); } if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const body = JSON.stringify({ success: true, motd }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.writeHead(302, { "Location": "/mod/motd" }).end(); } catch (err) { console.error('[MOD MOTD] Save failed:', err); const msg = 'Failed to save MOTD: ' + err.message; if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const body = JSON.stringify({ success: false, msg }); return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.reply({ code: 500, body: msg }); } }); // Pin / Unpin Item router.post(/^\/mod\/pin\/?/, lib.modAuth, async (req, res) => { if (!req.url.qs?.id) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "No ID provided" })); const id = +req.url.qs.id; const result = await db`update "items" set is_pinned = true where id = ${id} and active = true`; if (result.count === 1) { await audit.log(req.session.id, 'pin_item', 'item', id); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, pinned: true })); } return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Item not found or not active" })); }); router.post(/^\/mod\/unpin\/?/, lib.modAuth, async (req, res) => { if (!req.url.qs?.id) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "No ID provided" })); const id = +req.url.qs.id; const result = await db`update "items" set is_pinned = false where id = ${id}`; if (result.count === 1) { await audit.log(req.session.id, 'unpin_item', 'item', id); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, pinned: false })); } return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Item not found" })); }); // Hall Management router.post(/^\/mod\/halls\/add\/?$/, lib.modAuth, async (req, res) => { const { id, hall, description } = req.post; if (!id || !hall) { const body = JSON.stringify({ success: false, msg: "Missing id or hall" }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } const result = await f0cklib.addItemToHall(id, hall, req.session.id, description); if (result.success) { await audit.log(req.session.id, 'add_to_hall', 'item', +id, { hall, description }); } const body = JSON.stringify({ success: result.success, msg: result.message }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); }); router.post(/^\/mod\/halls\/remove\/?$/, lib.modAuth, async (req, res) => { const { id, hall } = req.post; if (!id || !hall) { const body = JSON.stringify({ success: false, msg: "Missing id or hall" }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } const result = await f0cklib.removeItemFromHall(id, hall); if (result.success) { await audit.log(req.session.id, 'remove_from_hall', 'item', +id, { hall }); } const body = JSON.stringify({ success: result.success, msg: result.message }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); }); router.post(/^\/mod\/halls\/update\/?$/, lib.modAuth, async (req, res) => { const { hall, description } = req.post; if (!hall) { const body = JSON.stringify({ success: false, msg: "Missing hall slug" }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } const result = await f0cklib.updateHallMetadata(hall, description); if (result.success) { await audit.log(req.session.id, 'update_hall_metadata', 'hall', 0, { hall, description }); } const body = JSON.stringify({ success: result.success, msg: result.message }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); }); // Hall Manager page (also accessible by mods) router.get(/^\/mod\/halls\/?$/, lib.modAuth, async (req, res) => { const hallsList = await f0cklib.getHalls(); res.reply({ body: tpl.render('admin/halls', { session: req.session, hallsList, tmp: null }, req) }); }); return router; };