import db from "../sql.mjs"; import audit from "../audit.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; import { safeDeleteMediaFile } from "../lib_delete.mjs"; import lib from "../lib.mjs"; import { setMotd } from "../motd.mjs"; import { setAboutText, setRulesText, setTermsText } from "../page_texts.mjs"; import { exec } from "child_process"; import { promises as fs } from "fs"; import cfg from "../config.mjs"; import security from "../security.mjs"; import crypto from "crypto"; import path from "path"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs"; export default (router, tpl) => { router.get(/^\/login(\/)?$/, async (req, res) => { if (req.session) { return res.writeHead(302, { "Location": "/?already_logged_in=1" }).end(); } res.redirect('/?login=1'); }); router.post(/^\/login(\/)?$/, async (req, res) => { const ip = security.getRealIP(req); const username = req.post.username; const password = req.post.password; const fail = async (msg) => { await security.recordAttempt(ip, username, 'login', false); // Artificial delay to prevent timing attacks and slow down brute-force await new Promise(resolve => setTimeout(resolve, 1000)); if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); } return res.reply({ body: tpl.render("login", { error: msg, theme: req.theme }) }); }; if (await security.isRateLimited(ip, null, 'login')) { const msg = "Too many attempts."; if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { return res.writeHead(429, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); } return res.reply({ code: 429, body: msg }); } if (!username || !password) { return fail("Invalid username or password."); } const user = await db` select id, password, activated, banned, ban_reason, ban_expires, force_password_change from "user" where "login" = ${username.toLowerCase()} or lower(email) = lower(${username.trim()}) limit 1 `; if (user.length === 0) return fail("user doesn't exist or wrong password"); if (!(await lib.verify(req.post.password, user[0].password))) return fail("user doesn't exist or wrong password"); if (!user[0].activated) return fail("This account is not activated. Please check your email."); if (user[0].banned) { const now = new Date(); if (user[0].ban_expires && new Date(user[0].ban_expires) < now) { // Ban expired, lift it await db`update "user" set banned = false, ban_reason = null, ban_expires = null where id = ${+user[0].id}`; user[0].banned = false; user[0].ban_reason = null; user[0].ban_expires = null; } else { const reason = user[0].ban_reason || 'none'; const expires = user[0].ban_expires ? new Date(user[0].ban_expires).toISOString().replace('T', ' ').substring(0, 16) : 'never'; return fail(`You are banned! reason: ${reason} expire: ${expires}`); } } await security.recordAttempt(ip, username, 'login', true); await security.clearAttempts(ip, username); const stamp = ~~(Date.now() / 1e3); // F-015: Clean up stale non-KMSI sessions unused for 7 days (on login) await db` delete from user_sessions where last_used <= ${stamp - 604800} and kmsi = 0 `; const session = crypto.randomBytes(32).toString('hex'); const csrfToken = crypto.randomBytes(32).toString('hex'); const blah = { user_id: user[0].id, session: lib.sha256(session), csrf_token: csrfToken, browser: req.headers["user-agent"], created_at: stamp, last_used: stamp, last_action: "/login", kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0, ip: ip }; await db` insert into "user_sessions" ${db(blah, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip') } `; // Log IP for historical data await security.logUserIP(user[0].id, ip); return res.writeHead(301, { "Cache-Control": "no-cache, public", "Set-Cookie": `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`, "Location": "/" }).end(); }); router.get(/^\/logout$/, async (req, res) => { if (req.session && req.session.sess_id) { await db` delete from "user_sessions" where id = ${+req.session.sess_id} `; } return res.writeHead(301, { "Cache-Control": "no-cache, public", "Set-Cookie": `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`, "Location": "/" }).end(); }); // Forgot Password router.get(/^\/forgot-password(\/)?$/, (req, res) => res.redirect('/')); router.post(/^\/forgot-password(\/)?$/, async (req, res) => { const { email } = req.post; const ip = security.getRealIP(req); const isAJAX = req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json')); if (!email) { const msg = "Email is required"; if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); return res.reply({ body: tpl.render("forgot-password", { error: msg }) }); } // 1. Initial IP-only check (anonymous) if (await security.isRateLimited(ip, null, 'password_reset_request')) { const msg = "Too many attempts. Please try again later."; if (isAJAX) return res.writeHead(429, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) }); } const user = (await db`select id, login from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0]; const targetIdentity = user ? user.login : email; // 2. Identity-based check if (await security.isRateLimited(ip, targetIdentity, 'password_reset_request')) { const msg = "Only one reset request per 24 hours is allowed for this account."; if (isAJAX) return res.writeHead(429, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) }); } // Record attempt (always success=true for rate limiting purposes if user exists, or false if not, // but the user wants "maximum tries per day is 1", so we record it regardless of email existence) await security.recordAttempt(ip, targetIdentity, 'password_reset_request', true); if (!user) { const msg = "If an account with that email exists, we have sent a reset link."; if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg })); return res.reply({ body: tpl.render("forgot-password", { success: msg }) }); } const token = crypto.randomBytes(32).toString('hex'); const expires = new Date(Date.now() + 3600000); // 1 hour await db`update "user" set reset_token = ${token}, reset_expires = ${expires} where id = ${user.id}`; const resetLink = `${cfg.main.url.full}/?token=${token}`; // Redirect to home with token const mailBody = `Hello,\n\nYou requested a password reset for your account. Please click the link below to set a new password:\n\n${resetLink}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this email.`; try { const { sendMail } = await import("../../lib/smtp.mjs"); if (cfg.smtp && cfg.smtp.enabled) { await sendMail(cfg.smtp, { to: email, subject: "Password Reset Request", body: mailBody }); } else { console.log(`[SMTP] No configuration found. Reset link: ${resetLink}`); } } catch (e) { console.error(`[SMTP] Send failed:`, e.message); } const msg = "If an account with that email exists, we have sent a reset link."; if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg })); res.reply({ body: tpl.render("forgot-password", { success: msg }) }); }); // Reset Password // Reset Password router.get(/^\/reset-password(\/)?$/, (req, res) => { const token = req.url.qs?.token; if (token) return res.redirect(`/?token=${token}`); res.redirect('/'); }); router.post(/^\/reset-password(\/)?$/, async (req, res) => { const { token, password, password_confirm } = req.post; const ip = security.getRealIP(req); const isAJAX = req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json')); if (!token || !password || !password_confirm) { if (isAJAX) return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing data' })); return res.redirect('/login'); } if (password !== password_confirm) { const msg = "Passwords do not match"; if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); return res.reply({ body: tpl.render("reset-password", { token, error: msg }) }); } if (password.length < 20) { const msg = "Password must be at least 20 characters long"; if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); return res.reply({ body: tpl.render("reset-password", { token, error: msg }) }); } // Rate limit the actual password reset execution too (prevent brute-forcing tokens or multiple resets) if (await security.isRateLimited(ip, null, 'password_reset_execution')) { const msg = "Too many reset attempts. Please try again later."; if (isAJAX) return res.writeHead(429, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) }); } const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() limit 1`)[0]; if (!user) { const msg = "Invalid or expired reset token."; if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); return res.reply({ body: tpl.render("forgot-password", { error: msg }) }); } const hash = await lib.hash(password); await db`update "user" set password = ${hash}, reset_token = NULL, reset_expires = NULL where id = ${user.id}`; // Invalidate all sessions for this user after password reset await db`delete from "user_sessions" where user_id = ${user.id}`; // Record successful reset execution await security.recordAttempt(ip, user.login, 'password_reset_execution', true); const msg = "Password reset successfully! You can now login with your new password."; if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg })); res.reply({ body: tpl.render("reset-password", { success: msg }) }); }); router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage res.reply({ body: tpl.render("admin", { totals: await lib.countf0cks(), session: req.session, manual_approval: getManualApproval(), log_user_ips: getLogUserIps(), hash_user_ips: getHashUserIps(), enable_cleanup: getEnableCleanup(), shitpost_mode: getShitpostMode(), enable_cleanup_config: cfg.websrv.enable_cleanup !== false, tmp: null }, req) }); }); router.get(/^\/admin\/sessions(\/)?$/, lib.auth, async (req, res) => { const rows = await db` select "user_sessions".*, "user".user from "user_sessions" left join "user" on "user".id = "user_sessions".user_id order by "user_sessions".last_used desc `; const now = ~~(Date.now() / 1e3); const activeThreshold = 15 * 60; // 15 minutes const activeUsernames = [...new Set( rows.filter(r => (now - r.last_used) < activeThreshold).map(r => r.user) )]; res.reply({ body: tpl.render("admin/sessions", { session: req.session, sessions: rows, activeUsers: activeUsernames.length, activeUserList: activeUsernames, totals: await lib.countf0cks(), log_user_ips: getLogUserIps(), tmp: null }, req) }); }); router.get(/\/admin\/user\/(?\d+)\/ips(\/)?$/, lib.auth, async (req, res) => { const userId = +req.params.userId; const user = await db`select "user", login from "user" where id = ${userId} limit 1`; if (user.length === 0) return res.reply({ code: 404, body: 'User not found' }); const rows = await db` select * from user_ips where user_id = ${userId} order by last_seen desc `; res.reply({ body: tpl.render("admin/user_ips", { session: req.session, targetUser: user[0], ips: rows, page_meta: { title: `IP History - ${user[0].user}` }, totals: await lib.countf0cks(), tmp: null }, req) }); }); router.post(/^\/api\/v2\/admin\/sessions\/delete\/?$/, lib.auth, async (req, res) => { try { const { id } = req.post; if (!id) throw new Error('Missing session ID'); // Prevent self-deletion of current session if (+id === +req.session.sess_id) { throw new Error('Cannot delete your current session'); } await db`delete from "user_sessions" where id = ${+id}`; if (res.json) return res.json({ success: true }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true })); } catch (err) { if (res.json) return res.json({ success: false, msg: err.message }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.get(/^\/admin\/approve\/?/, lib.modAuth, async (req, res) => { if (req.url.qs?.id) { return res.redirect(`/mod/approve?id=${req.url.qs.id}`); } return res.redirect("/mod/approve"); }); router.get(/^\/admin\/deny\/?/, lib.modAuth, async (req, res) => { // Forward ID if present if (req.url.qs?.id) { return res.redirect(`/mod/deny?id=${req.url.qs.id}`); } return res.redirect("/mod/approve"); }); // Deprecate bulk actions in admin route, suggest mod route router.post(/^\/admin\/deny-multi\/?/, lib.auth, async (req, res) => { return res.reply({ code: 307, headers: { Location: '/mod/deny-multi' } }); }); router.post(/^\/admin\/purge-trash-all\/?/, lib.auth, async (req, res) => { return res.reply({ code: 307, headers: { Location: '/mod/purge-trash-all' } }); }); // Token Routes router.get(/^\/admin\/tokens\/?$/, lib.auth, async (req, res) => { res.reply({ body: tpl.render("admin/tokens", { session: req.session, tmp: null }, req) }); }); router.get(/^\/api\/v2\/admin\/tokens\/?$/, lib.auth, async (req, res) => { const tokens = await db` select invite_tokens.*, u_used.user as used_by_name, u_created.user as created_by_name from invite_tokens left join "user" as u_used on u_used.id = invite_tokens.used_by left join "user" as u_created on u_created.id = invite_tokens.created_by order by created_at desc `; if (res.json) { return res.json({ success: true, tokens }); } // Fallback if res.json is not available return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, tokens })); }); router.post(/^\/api\/v2\/admin\/tokens\/create\/?$/, lib.auth, async (req, res) => { try { // 128-bit random token — replaces weak 10-char SHA256 slice (~40 bits) const token = crypto.randomBytes(16).toString('hex').toUpperCase(); await db` insert into invite_tokens (token, created_at, created_by) values (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id}) `; if (res.json) return res.json({ success: true, token }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token })); } catch (err) { const msg = lib.logError(err, 'Token creation failed'); if (res.json) return res.json({ success: false, msg }); return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); } }); router.post(/^\/api\/v2\/admin\/tokens\/delete\/?$/, lib.auth, async (req, res) => { if (!req.post.id) { if (res.json) return res.json({ success: false }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false })); } await db`delete from invite_tokens where id = ${req.post.id}`; if (res.json) return res.json({ success: true }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true })); }); router.post(/^\/api\/v2\/admin\/ban\/?$/, lib.modAuth, async (req, res) => { try { const { user_id, reason, duration } = req.post; // Check if target is an admin or protected account const targetUser = await db`select admin, "user", login from "user" where id = ${+user_id} limit 1`; if (targetUser.length === 0) { throw new Error('User not found'); } if (targetUser[0].login === 'deleted_user') { throw new Error('The deleted_user account is a protected system ghost and cannot be modified.'); } if (targetUser[0].admin && !req.session.admin) { const errorMsg = 'Moderators cannot ban administrators.'; if (res.json) return res.json({ success: false, msg: errorMsg }, 403); return res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg })); } // Enforce 48h limit for moderators if (!req.session.admin) { if (duration === 'permanent' || parseInt(duration) > 48) { const errorMsg = 'Moderators can only issue bans for up to 48 hours.'; if (res.json) return res.json({ success: false, msg: errorMsg }, 403); return res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg })); } } const expires = duration === 'permanent' ? null : new Date(Date.now() + parseInt(duration) * 3600000); await db` update "user" set banned = true, ban_reason = ${reason}, ban_expires = ${expires} where id = ${+user_id} `; // Log it in audit await audit.log(req.session.id, 'ban_user', 'user', +user_id, { reason, duration, target_user: targetUser[0].user }); // Terminate all sessions for this user await db`delete from "user_sessions" where user_id = ${+user_id}`; if (res.json) return res.json({ success: true }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true })); } catch (err) { if (res.json) return res.json({ success: false, msg: err.message }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/unban\/?$/, lib.modAuth, async (req, res) => { try { const { user_id } = req.post; if (!user_id) { throw new Error('Missing user_id'); } const target = await db`SELECT login FROM "user" WHERE id = ${+user_id} LIMIT 1`; if (target.length && target[0].login === 'deleted_user') { throw new Error('The deleted_user account is a protected system ghost and cannot be unbanned.'); } await db` update "user" set banned = false, ban_reason = null, ban_expires = null where id = ${+user_id} `; // Log it in audit await audit.log(req.session.id, 'unban_user', 'user', +user_id); if (res.json) return res.json({ success: true }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true })); } catch (err) { if (res.json) return res.json({ success: false, msg: err.message }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/subscribe-user-to-uploads\/?$/, lib.auth, async (req, res) => { try { const { user_id } = req.post; if (!user_id) { throw new Error('Missing user_id'); } // Fetch user info to ensure we match correctly const target = await db`SELECT login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`; if (!target.length) { throw new Error('User not found'); } const { login, user } = target[0]; // Robust subscription logic: Match items by username (case-insensitive) // using both login and display name for maximum coverage of legacy/renaming accounts. await db` INSERT INTO comment_subscriptions (user_id, item_id) SELECT ${+user_id}, i.id FROM items i WHERE i.username ILIKE ${login} OR i.username ILIKE ${user} ON CONFLICT DO NOTHING `; const response = { success: true, message: 'User subscribed to all their uploads' }; if (res.json) return res.json(response); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response)); } catch (err) { console.error('[ADMIN] Failed to subscribe user to uploads:', err); const response = { success: false, msg: err.message }; if (res.json) return res.json(response); return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify(response)); } }); router.get(/^\/admin\/motd\/?$/, lib.auth, 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("admin/motd", { session: req.session, motd: motd, totals: await lib.countf0cks(), tmp: null }, req) }); }); router.post(/^\/admin\/motd\/?$/, lib.auth, async (req, res) => { try { const motd = req.post?.motd ?? ''; await db`INSERT INTO site_settings (key, value) VALUES ('motd', ${motd}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; // Update global template variable safely via helper setMotd(motd); // Log it in audit await audit.log(req.session.id, 'update_motd', 'system', 0, { motd }); // Explicitly notify as a fallback and to ensure immediate broadcast in this process try { await db`SELECT pg_notify('motd', ${motd ? motd.substring(0, 7000) : ''})`; } catch (e) { console.error('[ADMIN] 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": "/admin/motd" }).end(); } catch (err) { console.error('[ADMIN] 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 }); } }); // Settings POST (Used by AJAX toggle on admin dashboard) router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => { const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false'; const registration_open = req.post.registration_open === 'on' ? 'true' : 'false'; const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags)); const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3); await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; if (cfg.websrv.open_registration_web_toggle !== false) { await db`INSERT INTO site_settings (key, value) VALUES ('registration_open', ${registration_open}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setRegistrationOpen(registration_open === 'true'); } await db`INSERT INTO site_settings (key, value) VALUES ('min_tags', ${min_tags.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; await db`INSERT INTO site_settings (key, value) VALUES ('trusted_uploads', ${trusted_uploads.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setManualApproval(manual_approval === 'true'); setMinTags(min_tags); setTrustedUploads(trusted_uploads); if (req.headers['x-requested-with'] === 'XMLHttpRequest') { res.setHeader('Content-Type', 'application/json'); return res.reply({ body: JSON.stringify({ success: true, manual_approval: getManualApproval(), registration_open: getRegistrationOpen(), min_tags: getMinTags(), trusted_uploads: getTrustedUploads() }) }); } return res.writeHead(302, { "Location": "/admin" }).end(); }); router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => { if (!getEnableCleanup()) { return res.redirect("/admin"); } const cleanup_payload = { session: req.session, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), totals: await lib.countf0cks(), tmp: null }; res.reply({ body: tpl.render("admin/cleanup", cleanup_payload, req) }); }); router.post(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => { try { const cleanup_start_date = req.post.cleanup_start_date || ''; const cleanup_end_date = req.post.cleanup_end_date || ''; await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setCleanupStartDate(cleanup_start_date); setCleanupEndDate(cleanup_end_date); if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.writeHead(302, { "Location": "/admin/cleanup" }).end(); } catch (err) { console.error('[ADMIN] Cleanup Settings Save failed:', err); const msg = 'Failed to save Cleanup settings: ' + 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 }); } }); router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => { try { // Ensure settings are synced from DB before execution const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`; const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value])); const isEnabled = settingsMap['enable_cleanup'] === 'true'; const startDate = settingsMap['cleanup_start_date'] || ''; const endDate = settingsMap['cleanup_end_date'] || ''; // Update memory state setEnableCleanup(isEnabled); setCleanupStartDate(startDate); setCleanupEndDate(endDate); if (!isEnabled) { throw new Error('Cleanup is disabled in settings.'); } if (!startDate || !endDate) { throw new Error('Please select both a Start Date and an End Date.'); } console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`); const start_stamp = ~~(new Date(startDate).getTime() / 1000); const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day // Diagnostics: Count candidates const totalCleanable = await db`SELECT count(*) as c FROM items WHERE is_purged = false AND is_pinned = false`; const withinRange = await db`SELECT count(*) as c FROM items WHERE is_purged = false AND is_pinned = false AND stamp >= ${start_stamp} AND stamp <= ${end_stamp}`; const oldItems = await db` SELECT i.id, i.dest, i.mime FROM items i WHERE i.is_purged = false AND i.is_pinned = false AND i.stamp >= ${start_stamp} AND i.stamp <= ${end_stamp} AND ( -- Case 1: Active posts with no engagement (ignoring automatic subscriptions) (i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id)) OR -- Case 2: Soft-deleted posts (trash) that are old enough to be purged (i.is_deleted = true) OR -- Case 3: Pending posts (not yet approved) that are old enough (i.active = false AND i.is_deleted = false) ) `; const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`; console.log(`[ADMIN] Cleanup diagnostic: found ${oldItems.length} targets. ${statsInfo}`); let count = 0; if (oldItems.length > 0) { for (const item of oldItems) { 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.t, `${item.id}_blur.webp`)).catch(() => { }); if (item.mime?.startsWith('audio')) { await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { }); } await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`; count++; } catch (e) { console.error(`[CLEANUP] Failed to delete item ${item.id}:`, e.message); } } } // Log it in audit await audit.log(req.session.id, 'run_cleanup_manual', 'system', 0, { count, startDate, endDate }); const response = { success: true, count, msg: `Successfully cleaned up ${count} posts. ${statsInfo}` }; if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const body = JSON.stringify(response); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } const cleanup_payload = { ...response, session: req.session, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), totals: await lib.countf0cks() }; return res.reply({ body: tpl.render("admin/cleanup", cleanup_payload, req) }); } catch (err) { console.error('[ADMIN] Cleanup execution failed:', err); const msg = 'Cleanup failed: ' + 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 }); } }); // User Management Routes router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => { const rawQ = req.url.qs?.q || ''; // Exact match mode: strip surrounding double quotes and match exactly const exactMatch = rawQ.startsWith('"') && rawQ.endsWith('"') && rawQ.length > 2; const q = exactMatch ? rawQ.slice(1, -1) : rawQ; const page = Math.max(1, parseInt(req.url.qs?.page) || 1); const limit = 50; const offset = (page - 1) * limit; const users = await db` WITH filtered_users AS ( SELECT u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated, uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode, (SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method FROM "user" u LEFT JOIN user_options uo ON uo.user_id = u.id ${q ? (exactMatch ? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})` : db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` ) : db``} ), ghost_users AS ( SELECT NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email, to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated, NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method FROM items i WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) ${q ? (exactMatch ? db`AND lower(i.username) = lower(${q})` : db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` ) : db``} GROUP BY i.username ), all_users AS ( SELECT * FROM filtered_users UNION ALL SELECT * FROM ghost_users ), paginated_users AS ( SELECT * FROM all_users ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} ) SELECT pu.*, EXTRACT(DAY FROM (now() - pu.created_at)) as age_days, COALESCE(ic.upload_count, 0) as upload_count, COALESCE(cc.comment_count, 0) as comment_count, COALESCE(la.failed_attempts, 0) as failed_attempts FROM paginated_users pu LEFT JOIN LATERAL ( SELECT COUNT(*) as upload_count FROM items WHERE (username = pu.login OR username = pu.user) AND is_deleted = false ) ic ON true LEFT JOIN LATERAL ( SELECT COUNT(*) as comment_count FROM comments WHERE user_id = pu.id AND is_deleted = false ) cc ON pu.id IS NOT NULL LEFT JOIN LATERAL ( SELECT COUNT(*) as failed_attempts FROM login_attempts WHERE username = pu.login AND success = false AND type = 'login' AND attempted_at > now() - interval '10 hours' ) la ON true `; const totalCountActual = await db` SELECT COUNT(*) as c FROM "user" u ${q ? (exactMatch ? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})` : db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` ) : db``} `; const totalCountGhost = await db` SELECT COUNT(DISTINCT i.username) as c FROM items i WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) ${q ? (exactMatch ? db`AND lower(i.username) = lower(${q})` : db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` ) : db``} `; const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c); const data = { session: req.session, users, q, page, total, hasMore: users.length === limit, totals: await lib.countf0cks(), log_user_ips: getLogUserIps(), tmp: null }; if (req.headers['x-requested-with'] === 'XMLHttpRequest') { res.setHeader('X-Total-Count', total.toString()); res.setHeader('X-Has-More', (users.length === limit).toString()); return res.reply({ body: tpl.render("admin/users_list", data, req) }); } res.reply({ body: tpl.render("admin/users", data, req) }); }); router.post(/^\/api\/v2\/admin\/users\/activate\/?$/, lib.auth, async (req, res) => { try { const { user_id } = req.post; if (!user_id) throw new Error('Missing user_id'); const result = await db` UPDATE "user" SET activated = true, activation_token = NULL WHERE id = ${+user_id} AND login != 'deleted_user' RETURNING login `; if (!result.length) throw new Error('User not found'); // Log it in audit await audit.log(req.session.id, 'manual_verify_user', 'user', +user_id, { target_login: result[0].login }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: 'User ' + result[0].login + ' manually verified.' })); } catch (err) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/set-password\/?$/, lib.auth, async (req, res) => { try { const { user_id, password } = req.post; if (!user_id || !password) throw new Error('Missing user_id or password'); if (password.length < 20) throw new Error('Password must be at least 20 characters'); const hash = await lib.hash(password); const updateResult = await db`UPDATE "user" SET password = ${hash}, force_password_change = true WHERE id = ${+user_id} AND login != 'deleted_user' RETURNING id`; if (!updateResult.length) throw new Error('User not found or account is protected.'); // Invalidate existing sessions await db`DELETE FROM "user_sessions" WHERE user_id = ${+user_id}`; // Log it in audit await audit.log(req.session.id, 'admin_reset_password', 'user', +user_id); const response = { success: true, msg: 'Password updated and user forced to change on next login.' }; return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response)); } catch (err) { return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => { try { const { user_id, mode, lock } = req.post; if (!user_id) throw new Error('Missing user_id'); const isLocked = lock === true || lock === 'true' || lock === 1; const targetMode = parseInt(mode, 10); const updateData = { force_comment_display_mode: isLocked ? 1 : 0 }; if (!isNaN(targetMode)) updateData.comment_display_mode = targetMode; const result = await db` UPDATE user_options SET ${db(updateData)} WHERE user_id = ${+user_id} RETURNING user_id `; if (!result.length) throw new Error('User options not found'); // Log it in audit await audit.log(req.session.id, isLocked ? 'lock_user_layout' : 'unlock_user_layout', 'user', +user_id, { mode: targetMode }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: 'User layout ' + (isLocked ? 'locked' : 'unlocked') + '.', force_comment_display_mode: isLocked ? 1 : 0, comment_display_mode: targetMode })); } catch (err) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => { try { const { user_id } = req.post; if (!user_id) throw new Error('Missing user_id'); // Get target user info const target = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`; if (!target.length) throw new Error('User not found'); if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be deleted.'); // Get deleted_user info const ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`; if (!ghost.length) throw new Error('Ghost account "deleted_user" not found. Please run migration.'); const targetId = target[0].id; const targetLogin = target[0].login; const targetUser = target[0].user; const ghostId = ghost[0].id; // START TRANSACTION await db.begin(async sql => { // 1. Reassign intellectual content (items, comments, tags) await sql`UPDATE items SET username = 'deleted_user' WHERE username ILIKE ${targetLogin} OR username ILIKE ${targetUser}`; await sql`UPDATE comments SET user_id = ${ghostId} WHERE user_id = ${targetId}`; await sql`UPDATE tags_assign SET user_id = ${ghostId} WHERE user_id = ${targetId}`; // 2. Reassign social/admin content (tokens, reports, messages, warnings, logs) await sql`UPDATE invite_tokens SET used_by = ${ghostId} WHERE used_by = ${targetId}`; await sql`UPDATE invite_tokens SET created_by = ${ghostId} WHERE created_by = ${targetId}`; await sql`UPDATE reports SET user_id = ${ghostId} WHERE user_id = ${targetId}`; await sql`UPDATE reports SET reporter_id = ${ghostId} WHERE reporter_id = ${targetId}`; await sql`UPDATE reports SET resolved_by = ${ghostId} WHERE resolved_by = ${targetId}`; await sql`UPDATE private_messages SET sender_id = ${ghostId} WHERE sender_id = ${targetId}`; await sql`UPDATE private_messages SET recipient_id = ${ghostId} WHERE recipient_id = ${targetId}`; await sql`UPDATE user_warnings SET user_id = ${ghostId} WHERE user_id = ${targetId}`; await sql`UPDATE user_warnings SET admin_id = ${ghostId} WHERE admin_id = ${targetId}`; // Fetch and reassign halls with collision detection const userHalls = await sql`SELECT * FROM user_halls WHERE user_id = ${targetId}`; const ghostHalls = await sql`SELECT slug FROM user_halls WHERE user_id = ${ghostId}`; const ghostSlugs = new Set(ghostHalls.map(h => h.slug)); const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom'); for (const hall of userHalls) { let finalSlug = hall.slug; if (ghostSlugs.has(hall.slug)) { finalSlug = `${hall.slug}-from-${targetId}`; while (ghostSlugs.has(finalSlug)) { finalSlug = `${hall.slug}-from-${targetId}-${crypto.randomBytes(2).toString('hex')}`; } } ghostSlugs.add(finalSlug); if (hall.custom_image) { // F-004 Security: Sanitize slugs before constructing file paths const safeSlug = path.basename(hall.slug); const safeFinalSlug = path.basename(finalSlug); const oldPath = path.join(CUSTOM_DIR, `u_${targetId}_${safeSlug}.webp`); const newPath = path.join(CUSTOM_DIR, `u_${ghostId}_${safeFinalSlug}.webp`); try { await fs.rename(oldPath, newPath); } catch (e) { console.warn(`[ADMIN_DELETE] Failed to move hall image: ${oldPath} -> ${newPath}`, e.message); } } await sql`UPDATE user_halls SET user_id = ${ghostId}, slug = ${finalSlug} WHERE id = ${hall.id}`; } await sql`UPDATE halls_assign SET user_id = ${ghostId} WHERE user_id = ${targetId}`; await sql`UPDATE user_halls_assign SET user_id = ${ghostId} WHERE user_id = ${targetId}`; try { await sql`UPDATE audit_log SET user_id = ${ghostId} WHERE user_id = ${targetId}`; } catch (e) {} // 3. Purge personal metadata and ephemeral data (sessions, options, pubkeys, views, subscriptions, favorites, etc) await sql`DELETE FROM user_sessions WHERE user_id = ${targetId}`; await sql`DELETE FROM user_options WHERE user_id = ${targetId}`; await sql`DELETE FROM user_pubkeys WHERE user_id = ${targetId}`; await sql`DELETE FROM user_video_views WHERE user_id = ${targetId}`; await sql`DELETE FROM comment_subscriptions WHERE user_id = ${targetId}`; await sql`DELETE FROM user_conversation_states WHERE user_id = ${targetId} OR other_id = ${targetId}`; await sql`DELETE FROM notifications WHERE user_id = ${targetId}`; await sql`DELETE FROM favorites WHERE user_id = ${targetId}`; await sql`DELETE FROM link_token WHERE user_id = ${targetId}`; await sql`DELETE FROM discord_queue WHERE user_id = ${targetId}`; // 4. Delete the user record itself await sql`DELETE FROM "user" WHERE id = ${targetId}`; }); // Log it in audit await audit.log(req.session.id, 'admin_delete_user', 'user', targetId, { target_login: targetLogin }); const response = { success: true, msg: 'User ' + targetLogin + ' deleted and content reassigned to deleted_user.' }; return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response)); } catch (err) { console.error('[ADMIN] Deletion failed:', err); return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/reassign-uploads\/?$/, lib.auth, async (req, res) => { try { const { source_user_id, source_username, target_username } = req.post; if (!source_user_id && !source_username) throw new Error('Missing source_user_id or source_username'); if (!target_username || !target_username.trim()) throw new Error('Missing target_username'); // Resolve source user (registered or ghost) let sourceLogin, sourceUser; if (source_user_id) { const source = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+source_user_id} LIMIT 1`; if (!source.length) throw new Error('Source user not found'); if (source[0].login === 'deleted_user') throw new Error('Cannot reassign uploads from the protected deleted_user account.'); sourceLogin = source[0].login; sourceUser = source[0].user; } else { // Ghost/legacy user — just use the username directly sourceLogin = source_username.trim(); sourceUser = source_username.trim(); } // Resolve target user const target = await db`SELECT id, login, "user" FROM "user" WHERE login ILIKE ${target_username.trim()} LIMIT 1`; if (!target.length) throw new Error('Target user "' + target_username.trim() + '" not found'); const targetLogin = target[0].login; const targetId = target[0].id; if (source_user_id && +source_user_id === targetId) throw new Error('Source and target user are the same.'); // Reassign all items const result = await db` UPDATE items SET username = ${targetLogin} WHERE username ILIKE ${sourceLogin} OR username ILIKE ${sourceUser} `; // Log in audit await audit.log(req.session.id, 'admin_reassign_uploads', 'user', source_user_id ? +source_user_id : null, { source_login: sourceLogin, target_login: targetLogin, target_id: targetId, count: result.count }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, count: result.count, msg: `Successfully reassigned ${result.count} uploads from ${sourceLogin} to ${targetLogin}.` })); } catch (err) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/bulk-delete-items\/?$/, lib.auth, async (req, res) => { try { const { user_id, username } = req.post; if (!user_id && !username) throw new Error('Missing user_id or username'); let login, user; if (user_id) { const target = await db`SELECT login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`; if (target.length) { login = target[0].login; user = target[0].user; } } if (!login && username) { login = username; user = username; } if (!login) throw new Error('User not found'); // Mark all items as deleted and inactive const result = await db` UPDATE items SET is_deleted = true, active = false WHERE username ILIKE ${login} OR username ILIKE ${user} `; // Log it in audit await audit.log(req.session.id, 'admin_bulk_delete_items', 'user', user_id ? +user_id : null, { target_login: login, count: result.count }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, count: result.count, msg: `Successfully marked ${result.count} items as deleted.` })); } catch (err) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/bulk-delete-comments\/?$/, lib.auth, async (req, res) => { try { const { user_id } = req.post; if (!user_id) throw new Error('Missing user_id'); const result = await db`DELETE FROM comments WHERE user_id = ${+user_id}`; // Log it in audit await audit.log(req.session.id, 'admin_bulk_delete_comments', 'user', +user_id, { count: result.count }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, count: result.count, msg: `Successfully deleted ${result.count} comments.` })); } catch (err) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/reset-login-attempts\/?$/, lib.auth, async (req, res) => { try { const { username } = req.post; if (!username) throw new Error('Missing username'); // Clear attempts for this specific username await security.clearAttempts(null, username); // Attempt to find ID for audit if possible const target = await db`SELECT id FROM "user" WHERE login = ${username} LIMIT 1`; await audit.log(req.session.id, 'admin_reset_login_attempts', 'user', target.length ? target[0].id : null, { target_login: username }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: `Successfully reset login attempts for ${username}.` })); } catch (err) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); router.post(/^\/api\/v2\/admin\/users\/bulk-delete-halls\/?$/, lib.auth, async (req, res) => { try { const { user_id } = req.post; if (!user_id) throw new Error('Missing user_id'); // 1. Get all hall slugs for this user to clean up images const halls = await db`SELECT slug FROM user_halls WHERE user_id = ${+user_id}`; // 2. Perform DB deletion const result = await db`DELETE FROM user_halls WHERE user_id = ${+user_id}`; // user_halls_assign has no user_id, it is linked via hall_id. // cascading deletes should handle the assignments if set up, // but let's be safe and ensure no orphans if linked by user_id elsewhere await db`DELETE FROM user_halls_assign WHERE user_id = ${+user_id}`; // 3. Clean up custom hall images on disk const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom'); for (const h of halls) { const imgPath = path.join(CUSTOM_DIR, `u_${+user_id}_${h.slug}.webp`); await fs.unlink(imgPath).catch(() => {}); } // Log it in audit await audit.log(req.session.id, 'admin_bulk_delete_halls', 'user', +user_id, { count: result.count }); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, count: result.count, msg: `Successfully deleted ${result.count} halls and all assignments.` })); } catch (err) { return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); // About page text editor router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => { const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`; const about_text = settings.length > 0 ? settings[0].value : ''; res.reply({ body: tpl.render("admin/about", { session: req.session, about_text: about_text, totals: await lib.countf0cks(), tmp: null }, req) }); }); router.post(/^\/admin\/about\/?$/, lib.auth, async (req, res) => { try { const about_text = req.post?.about_text ?? ''; await db`INSERT INTO site_settings (key, value) VALUES ('about_text', ${about_text}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setAboutText(about_text); await audit.log(req.session.id, 'update_about_text', 'system', null); if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const body = JSON.stringify({ success: true }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.writeHead(302, { "Location": "/admin/about" }).end(); } catch (err) { console.error('[ADMIN] About Save failed:', err); const msg = 'Failed to save about text: ' + 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 }); } }); // Rules page text editor router.get(/^\/admin\/rules\/?$/, lib.auth, async (req, res) => { const settings = await db`SELECT value FROM site_settings WHERE key = 'rules_text' LIMIT 1`; const rules_text = settings.length > 0 ? settings[0].value : ''; res.reply({ body: tpl.render("admin/rules", { session: req.session, rules_text: rules_text, totals: await lib.countf0cks(), tmp: null }, req) }); }); router.post(/^\/admin\/rules\/?$/, lib.auth, async (req, res) => { try { const rules_text = req.post?.rules_text ?? ''; await db`INSERT INTO site_settings (key, value) VALUES ('rules_text', ${rules_text}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setRulesText(rules_text); await audit.log(req.session.id, 'update_rules_text', 'system', null); if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const body = JSON.stringify({ success: true }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.writeHead(302, { "Location": "/admin/rules" }).end(); } catch (err) { console.error('[ADMIN] Rules Save failed:', err); const msg = 'Failed to save rules text: ' + 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 }); } }); // Terms page text editor router.get(/^\/admin\/terms\/?$/, lib.auth, async (req, res) => { const settings = await db`SELECT value FROM site_settings WHERE key = 'terms_text' LIMIT 1`; const terms_text = settings.length > 0 ? settings[0].value : ''; res.reply({ body: tpl.render("admin/terms", { session: req.session, terms_text: terms_text, totals: await lib.countf0cks(), tmp: null }, req) }); }); router.post(/^\/admin\/terms\/?$/, lib.auth, async (req, res) => { try { const terms_text = req.post?.terms_text ?? ''; await db`INSERT INTO site_settings (key, value) VALUES ('terms_text', ${terms_text}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setTermsText(terms_text); await audit.log(req.session.id, 'update_terms_text', 'system', null); if (req.headers['x-requested-with'] === 'XMLHttpRequest') { const body = JSON.stringify({ success: true }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } return res.writeHead(302, { "Location": "/admin/terms" }).end(); } catch (err) { console.error('[ADMIN] Terms Save failed:', err); const msg = 'Failed to save terms text: ' + 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 }); } }); // Set display name (stylized nick overlay) router.post(/^\/api\/v2\/admin\/users\/set-display-name\/?$/, lib.auth, async (req, res) => { try { const { user_id, display_name } = req.post; if (!user_id) throw new Error('Missing user_id'); const target = await db`SELECT login, "user" FROM "user" WHERE id = ${+user_id} AND login != 'deleted_user' LIMIT 1`; if (!target.length) throw new Error('User not found or account is protected.'); const trimmed = (display_name || '').trim(); await db` INSERT INTO user_options (user_id, display_name, mode, theme, excluded_tags) VALUES (${+user_id}, ${trimmed || null}, 0, ${'4d'}, ${[]}::integer[]) ON CONFLICT (user_id) DO UPDATE SET display_name = ${trimmed || null} `; await audit.log(req.session.id, 'admin_set_display_name', 'user', +user_id, { target_login: target[0].login, display_name: trimmed || null }); const response = { success: true, display_name: trimmed || null, msg: trimmed ? `Display name set to "${trimmed}"` : 'Display name cleared.' }; // Notify the target user's live session so their navbar updates instantly db.notify('profile_update', JSON.stringify({ user_id: +user_id, user: target[0].user, display_name: trimmed || null })).catch(() => {}); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response)); } catch (err) { return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); } }); // Hall Manager router.get(/^\/admin\/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) }); }); // Chat Manager router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => { if (!cfg.websrv.enable_global_chat) { return res.redirect("/admin"); } res.reply({ body: tpl.render('admin/chat', { session: req.session, totals: await lib.countf0cks(), tmp: null }, req) }); }); return router; }