Files
f0ckm/src/inc/routes/chat.mjs
2026-05-04 04:24:18 +02:00

256 lines
11 KiB
JavaScript

import db from "../sql.mjs";
import cfg from "../config.mjs";
const MAX_MSG_LEN = 500;
const RATE_LIMIT_MS = 1000; // 1s between messages
const userLastMsg = new Map();
// Cached state — loaded from DB on first use, written through on every change
let chatBackground = null;
let chatTopic = null;
let _settingsLoaded = false;
async function loadSettings() {
if (_settingsLoaded) return;
_settingsLoaded = true;
try {
const rows = await db`SELECT key, value FROM global_chat_settings WHERE key IN ('background','topic')`;
for (const row of rows) {
if (row.key === 'background') chatBackground = row.value || null;
if (row.key === 'topic') chatTopic = row.value || null;
}
} catch (err) {
// Table may not exist yet (migration not applied) — degrade gracefully
console.warn('[Chat] Could not load settings (run global_chat_settings.sql migration):', err.message);
}
}
async function saveSetting(key, value) {
try {
await db`
INSERT INTO global_chat_settings (key, value) VALUES (${key}, ${value})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
} catch (err) {
console.warn('[Chat] Could not persist setting:', key, err.message);
}
}
// Build allowed-host regex from config
function buildBgHostRegex() {
const siteHost = new URL(cfg.main.url.base || `http://${cfg.main.url.domain}`).host;
const escaped = [siteHost, ...(cfg.websrv.allowed_comment_images || [])].map(h =>
h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
return new RegExp(`^https?://(?:${escaped.join('|')})/`, 'i');
}
export default (router, tpl) => {
// GET /api/chat — fetch recent messages
router.get('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session) {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
}
try {
const messages = await db`
SELECT gc.id, gc.user_id, gc.message, gc.created_at,
u.user as username, uo.avatar, uo.avatar_file,
uo.username_color, uo.display_name
FROM global_chat gc
JOIN "user" u ON u.id = gc.user_id
LEFT JOIN user_options uo ON uo.user_id = gc.user_id
ORDER BY gc.created_at DESC
LIMIT 50
`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, messages: messages.reverse() })
});
} catch (err) {
console.error('[Chat] GET error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// POST /api/chat — send a message
router.post('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session) {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
}
// F-007 Security: Block banned users from chatting
if (req.session.banned) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'You are banned' }) });
}
const message = (req.post?.message || '').trim();
if (!message || message.length > MAX_MSG_LEN) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Invalid message' }) });
}
// Rate limit
const now = Date.now();
const lastMs = userLastMsg.get(req.session.id) || 0;
if (now - lastMs < RATE_LIMIT_MS) {
return res.reply({ code: 429, body: JSON.stringify({ success: false, msg: 'Slow down!' }) });
}
userLastMsg.set(req.session.id, now);
try {
await db`
INSERT INTO global_chat (user_id, message)
VALUES (${req.session.id}, ${message})
`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
console.error('[Chat] POST error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// GET /api/chat/background — return current background CSS
router.get('/api/chat/background', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
await loadSettings();
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, background: chatBackground })
});
});
// POST /api/chat/background — admin: set chat panel background
router.post('/api/chat/background', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
const { url, opts } = req.post || {};
if (!url) {
// Clear background
chatBackground = null;
await saveSetting('background', null);
await db`SELECT pg_notify('global_chat_background', ${JSON.stringify({ background: null })})`;
return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true }) });
}
// Validate URL against allowed hosts
const hostRegex = buildBgHostRegex();
if (!hostRegex.test(url)) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL not from an allowed host' }) });
}
// Build the CSS background shorthand
const safeOpts = (opts || 'center / cover no-repeat')
.replace(/[;<>{}]/g, '');
const css = `url(${JSON.stringify(url)}) ${safeOpts}`;
chatBackground = css;
await saveSetting('background', css);
try {
await db`SELECT pg_notify('global_chat_background', ${JSON.stringify({ background: css })})`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, background: css })
});
} catch (err) {
console.error('[Chat] BACKGROUND error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// GET /api/chat/topic — return current topic text
router.get('/api/chat/topic', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
await loadSettings();
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, topic: chatTopic })
});
});
// POST /api/chat/topic — admin: set or clear the pinned topic
router.post('/api/chat/topic', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
const raw = (req.post?.topic || '').trim();
chatTopic = raw || null;
await saveSetting('topic', chatTopic);
try {
await db`SELECT pg_notify('global_chat_topic', ${JSON.stringify({ topic: chatTopic })})`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, topic: chatTopic })
});
} catch (err) {
console.error('[Chat] TOPIC error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// DELETE /api/chat/:id — admin: delete a single message
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
const id = parseInt(req.params.id, 10);
if (!id) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
try {
await db`DELETE FROM global_chat WHERE id = ${id}`;
await db`SELECT pg_notify('global_chat_delete', ${JSON.stringify({ id })})`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
console.error('[Chat] DELETE message error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// DELETE /api/chat — admin: clear all messages
router.delete('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
}
try {
await db`TRUNCATE global_chat RESTART IDENTITY`;
await db`SELECT pg_notify('global_chat_clear', '')`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true })
});
} catch (err) {
console.error('[Chat] CLEAR error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router;
};