diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 1f518ae..8b26b01 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -2,6 +2,7 @@ import { promises as fs } from "fs"; import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; +import { applyWordFilter } from '../../wordfilter.mjs'; import queue from '../../queue.mjs'; import path from "path"; @@ -82,12 +83,13 @@ export default router => { const saveComment = async (itemid, userid, content) => { if (!content || !content.trim()) return; try { + const filteredContent = await applyWordFilter(content); await db` INSERT INTO comments ${db({ item_id: itemid, user_id: userid, parent_id: null, - content: content.trim() + content: filteredContent.trim() }, 'item_id', 'user_id', 'parent_id', 'content')} `; } catch (err) { diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index 8a2b4da..0e36930 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -4,6 +4,7 @@ import cfg from "../config.mjs"; import lib from "../lib.mjs"; import audit from "../audit.mjs"; import { promises as fs } from "fs"; +import { applyWordFilter } from "../wordfilter.mjs"; import path from "path"; export default (router, tpl) => { @@ -252,7 +253,8 @@ export default (router, tpl) => { const body = req.post || {}; const item_id = parseInt(body.item_id, 10); const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null; - const content = body.content; + let content = body.content; + content = await applyWordFilter(content); const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time))) ? parseFloat(body.video_time) : null; @@ -713,7 +715,8 @@ export default (router, tpl) => { const commentId = req.params.id; const body = req.post || {}; - const content = body.content; + let content = body.content; + content = await applyWordFilter(content); if (!content || !content.trim()) { return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) }); diff --git a/src/inc/routes/wordfilter.mjs b/src/inc/routes/wordfilter.mjs new file mode 100644 index 0000000..6a87520 --- /dev/null +++ b/src/inc/routes/wordfilter.mjs @@ -0,0 +1,83 @@ +import db from "../sql.mjs"; +import lib from "../lib.mjs"; +import audit from "../audit.mjs"; + +export default (router, tpl) => { + // Auto-migration on startup + db`CREATE TABLE IF NOT EXISTS public.wordfilter ( + id SERIAL PRIMARY KEY, + word VARCHAR(255) NOT NULL UNIQUE, + replacement VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + )`.catch(err => console.error('[WORDFILTER] DB Table Setup Failed:', err)); + + // Admin View Render + router.get(/^\/admin\/wordfilter\/?$/, lib.auth, async (req, res) => { + res.reply({ + body: tpl.render("admin/wordfilter", { + session: req.session, + totals: await lib.countf0cks(), + csrf_token: req.session?.csrf_token || '' + }, req) + }); + }); + + // API list rules + router.get('/api/v2/admin/wordfilter', lib.auth, async (req, res) => { + try { + const filters = await db`SELECT * FROM wordfilter ORDER BY created_at DESC`; + return res.json({ success: true, filters }); + } catch (err) { + return res.json({ success: false, msg: err.message }, 500); + } + }); + + // API create rule + router.post('/api/v2/admin/wordfilter', lib.auth, async (req, res) => { + try { + const { word, replacement } = req.post || {}; + if (!word || !word.trim() || !replacement || !replacement.trim()) { + return res.json({ success: false, msg: 'Word and replacement are required' }, 400); + } + + const cleanWord = word.trim(); + const cleanReplacement = replacement.trim(); + + // Ensure no infinite loops or matches + if (cleanWord.toLowerCase() === cleanReplacement.toLowerCase()) { + return res.json({ success: false, msg: 'Word and replacement cannot be identical' }, 400); + } + + await db` + INSERT INTO wordfilter (word, replacement) + VALUES (${cleanWord}, ${cleanReplacement}) + ON CONFLICT (word) DO UPDATE SET replacement = EXCLUDED.replacement + `; + + await audit.log(req.session.id, 'add_wordfilter', 'wordfilter', 0, { word: cleanWord, replacement: cleanReplacement }); + + return res.json({ success: true }); + } catch (err) { + return res.json({ success: false, msg: err.message }, 500); + } + }); + + // API delete rule + router.post('/api/v2/admin/wordfilter/delete', lib.auth, async (req, res) => { + try { + const { id } = req.post || {}; + if (!id) return res.json({ success: false, msg: 'ID required' }, 400); + + const rows = await db`DELETE FROM wordfilter WHERE id = ${id} RETURNING word`; + if (rows.length > 0) { + await audit.log(req.session.id, 'delete_wordfilter', 'wordfilter', id, { word: rows[0].word }); + } + + return res.json({ success: true }); + } catch (err) { + return res.json({ success: false, msg: err.message }, 500); + } + }); + + return router; +}; diff --git a/src/inc/wordfilter.mjs b/src/inc/wordfilter.mjs new file mode 100644 index 0000000..315e7f9 --- /dev/null +++ b/src/inc/wordfilter.mjs @@ -0,0 +1,32 @@ +import db from "./sql.mjs"; + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Replaces words matching database rules inside comments. + * @param {string} content + * @returns {Promise} The filtered comment text. + */ +export async function applyWordFilter(content) { + if (!content || typeof content !== 'string') return content; + try { + const filters = await db`SELECT word, replacement FROM wordfilter`; + if (filters.length === 0) return content; + + let filtered = content; + for (const f of filters) { + const escaped = escapeRegExp(f.word); + const startBoundary = /^\w/.test(f.word) ? '\\b' : ''; + const endBoundary = /\w$/.test(f.word) ? '\\b' : ''; + + const regex = new RegExp(`${startBoundary}${escaped}${endBoundary}`, 'gi'); + filtered = filtered.replace(regex, f.replacement); + } + return filtered; + } catch (err) { + console.error('[WORDFILTER] Error applying filter:', err); + return content; + } +} diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 04f60fd..1498bf6 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -2,6 +2,7 @@ import { promises as fs } from "fs"; import db from "./inc/sql.mjs"; import lib from "./inc/lib.mjs"; import cfg from "./inc/config.mjs"; +import { applyWordFilter } from "./inc/wordfilter.mjs"; import queue from "./inc/queue.mjs"; import path from "path"; import https from "https"; @@ -421,11 +422,12 @@ export const handleUpload = async (req, res, self) => { // Insert optional first comment if (comment && comment.length > 0) { try { + const filteredComment = await applyWordFilter(comment); await db` INSERT INTO comments ${db({ item_id: itemid, user_id: req.session.id, - content: comment + content: filteredComment })} `; } catch (err) { diff --git a/views/admin.html b/views/admin.html index 3b03388..22b1fee 100644 --- a/views/admin.html +++ b/views/admin.html @@ -20,6 +20,7 @@
  • Meme Manager
  • Hall Manager
  • MOTD Manager
  • +
  • Wordfilter Manager
  • @if(enable_cleanup)
  • Cleanup Manager
  • @endif diff --git a/views/admin/wordfilter.html b/views/admin/wordfilter.html new file mode 100644 index 0000000..a111d9f --- /dev/null +++ b/views/admin/wordfilter.html @@ -0,0 +1,150 @@ +@include(snippets/header) + +
    +
    +
    +
    +

    Wordfilter Manager

    + ← Back to Dashboard +
    + + +
    +

    Create Wordfilter Rule

    +
    +
    + + +
    +
    + + +
    + +
    + +
    + + +
    + + + + + + + + + + + + +
    Original WordReplacementCreatedActions
    + +
    + + +
    +
    +
    +@include(snippets/footer)