add wordfilter
This commit is contained in:
@@ -2,6 +2,7 @@ import { promises as fs } from "fs";
|
|||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
|
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||||
import queue from '../../queue.mjs';
|
import queue from '../../queue.mjs';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -82,12 +83,13 @@ export default router => {
|
|||||||
const saveComment = async (itemid, userid, content) => {
|
const saveComment = async (itemid, userid, content) => {
|
||||||
if (!content || !content.trim()) return;
|
if (!content || !content.trim()) return;
|
||||||
try {
|
try {
|
||||||
|
const filteredContent = await applyWordFilter(content);
|
||||||
await db`
|
await db`
|
||||||
INSERT INTO comments ${db({
|
INSERT INTO comments ${db({
|
||||||
item_id: itemid,
|
item_id: itemid,
|
||||||
user_id: userid,
|
user_id: userid,
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
content: content.trim()
|
content: filteredContent.trim()
|
||||||
}, 'item_id', 'user_id', 'parent_id', 'content')}
|
}, 'item_id', 'user_id', 'parent_id', 'content')}
|
||||||
`;
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import cfg from "../config.mjs";
|
|||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import audit from "../audit.mjs";
|
import audit from "../audit.mjs";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
|
import { applyWordFilter } from "../wordfilter.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
@@ -252,7 +253,8 @@ export default (router, tpl) => {
|
|||||||
const body = req.post || {};
|
const body = req.post || {};
|
||||||
const item_id = parseInt(body.item_id, 10);
|
const item_id = parseInt(body.item_id, 10);
|
||||||
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
|
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)))
|
const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time)))
|
||||||
? parseFloat(body.video_time)
|
? parseFloat(body.video_time)
|
||||||
: null;
|
: null;
|
||||||
@@ -713,7 +715,8 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const commentId = req.params.id;
|
const commentId = req.params.id;
|
||||||
const body = req.post || {};
|
const body = req.post || {};
|
||||||
const content = body.content;
|
let content = body.content;
|
||||||
|
content = await applyWordFilter(content);
|
||||||
|
|
||||||
if (!content || !content.trim()) {
|
if (!content || !content.trim()) {
|
||||||
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
|
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
|
||||||
|
|||||||
83
src/inc/routes/wordfilter.mjs
Normal file
83
src/inc/routes/wordfilter.mjs
Normal file
@@ -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;
|
||||||
|
};
|
||||||
32
src/inc/wordfilter.mjs
Normal file
32
src/inc/wordfilter.mjs
Normal file
@@ -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<string>} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from "fs";
|
|||||||
import db from "./inc/sql.mjs";
|
import db from "./inc/sql.mjs";
|
||||||
import lib from "./inc/lib.mjs";
|
import lib from "./inc/lib.mjs";
|
||||||
import cfg from "./inc/config.mjs";
|
import cfg from "./inc/config.mjs";
|
||||||
|
import { applyWordFilter } from "./inc/wordfilter.mjs";
|
||||||
import queue from "./inc/queue.mjs";
|
import queue from "./inc/queue.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
@@ -421,11 +422,12 @@ export const handleUpload = async (req, res, self) => {
|
|||||||
// Insert optional first comment
|
// Insert optional first comment
|
||||||
if (comment && comment.length > 0) {
|
if (comment && comment.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
const filteredComment = await applyWordFilter(comment);
|
||||||
await db`
|
await db`
|
||||||
INSERT INTO comments ${db({
|
INSERT INTO comments ${db({
|
||||||
item_id: itemid,
|
item_id: itemid,
|
||||||
user_id: req.session.id,
|
user_id: req.session.id,
|
||||||
content: comment
|
content: filteredComment
|
||||||
})}
|
})}
|
||||||
`;
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<li><a href="/admin/memes">Meme Manager</a></li>
|
<li><a href="/admin/memes">Meme Manager</a></li>
|
||||||
<li><a href="/admin/halls">Hall Manager</a></li>
|
<li><a href="/admin/halls">Hall Manager</a></li>
|
||||||
<li><a href="/admin/motd">MOTD Manager</a></li>
|
<li><a href="/admin/motd">MOTD Manager</a></li>
|
||||||
|
<li><a href="/admin/wordfilter">Wordfilter Manager</a></li>
|
||||||
@if(enable_cleanup)
|
@if(enable_cleanup)
|
||||||
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
|
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
150
views/admin/wordfilter.html
Normal file
150
views/admin/wordfilter.html
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
<style>
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.container:not(:has(.item-layout-container)) {
|
||||||
|
max-width: 1140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="pagewrapper">
|
||||||
|
<div id="main" class="admin-container">
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
|
||||||
|
<h2>Wordfilter Manager</h2>
|
||||||
|
<a href="/admin" class="btn-upload" style="width: auto; padding: 8px 16px; text-decoration: none; font-size: 0.9em; display: inline-flex; align-items: center;">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rule Add Section -->
|
||||||
|
<div class="wordfilter-form-container" style="background: rgba(0,0,0,0.2); padding: 20px; border-radius: 6px; margin-bottom: 30px; border: 1px solid rgba(255,255,255,0.05);">
|
||||||
|
<h4 style="color: var(--accent); margin-top: 0; margin-bottom: 15px;">Create Wordfilter Rule</h4>
|
||||||
|
<form id="wordfilter-form" onsubmit="event.preventDefault(); addRule(this);" style="display: flex; gap: 15px; align-items: flex-end; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 1; min-width: 200px;">
|
||||||
|
<label for="word" style="display: block; margin-bottom: 6px; font-size: 0.9em; color: #ccc;">Target Word</label>
|
||||||
|
<input type="text" id="word" name="word" required placeholder="Word to match..." style="width: 100%; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 10px; border-radius: 4px; font-size: 1em;">
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 200px;">
|
||||||
|
<label for="replacement" style="display: block; margin-bottom: 6px; font-size: 0.9em; color: #ccc;">Replacement Word</label>
|
||||||
|
<input type="text" id="replacement" name="replacement" required placeholder="Replace with..." style="width: 100%; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 10px; border-radius: 4px; font-size: 1em;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-upload" style="width: auto; padding: 11px 25px; margin-bottom: 1px;">Add Rule</button>
|
||||||
|
</form>
|
||||||
|
<span id="form-status" style="margin-top: 10px; display: block; font-weight: bold; font-size: 0.9em;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Rules List -->
|
||||||
|
<div class="wordfilter-table-container" style="background: rgba(0,0,0,0.1); border-radius: 6px; padding: 5px; border: 1px solid rgba(255,255,255,0.05);">
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Original Word</th>
|
||||||
|
<th>Replacement</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th style="text-align: right;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="filter-list">
|
||||||
|
<!-- Dynamically loaded -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="no-rules-msg" style="text-align: center; padding: 40px; color: #aaa; display: none;">
|
||||||
|
No wordfilter rules active. Add one above!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const loadRules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/admin/wordfilter');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
const tbody = document.getElementById('filter-list');
|
||||||
|
const noRulesMsg = document.getElementById('no-rules-msg');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (!data.filters || data.filters.length === 0) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
noRulesMsg.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noRulesMsg.style.display = 'none';
|
||||||
|
tbody.innerHTML = data.filters.map(f =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td data-label="Original Word" style="font-weight: bold; color: var(--accent);">' + escapeHtml(f.word) + '</td>' +
|
||||||
|
'<td data-label="Replacement" style="font-family: monospace; color: #fff;">' + escapeHtml(f.replacement) + '</td>' +
|
||||||
|
'<td data-label="Created">' + (f.created_at ? new Date(f.created_at).toLocaleString() : '—') + '</td>' +
|
||||||
|
'<td data-label="Actions" style="text-align: right;">' +
|
||||||
|
'<button onclick="deleteRule(' + f.id + ')" class="btn-remove" style="padding: 5px 12px; font-size: 0.85em; border-radius: 4px; border: 0; cursor: pointer;">Delete</button>' +
|
||||||
|
'</td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load rules:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRule = async (form) => {
|
||||||
|
const status = document.getElementById('form-status');
|
||||||
|
status.textContent = 'Saving...';
|
||||||
|
status.style.color = 'var(--accent)';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/admin/wordfilter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(new FormData(form))
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
status.textContent = 'Rule added successfully!';
|
||||||
|
status.style.color = '#28a745';
|
||||||
|
form.reset();
|
||||||
|
loadRules();
|
||||||
|
setTimeout(() => status.textContent = '', 3000);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.msg || 'Save failed');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'Error: ' + e.message;
|
||||||
|
status.style.color = '#d9534f';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRule = async (id) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this wordfilter rule?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/admin/wordfilter/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadRules();
|
||||||
|
} else {
|
||||||
|
alert('Delete failed: ' + data.msg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error deleting: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize view
|
||||||
|
loadRules();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@include(snippets/footer)
|
||||||
Reference in New Issue
Block a user