256 lines
11 KiB
JavaScript
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;
|
|
};
|