From 8a24564cd9c30e5fee34aded5b8417513c5309c8 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 19 Jun 2026 14:44:56 +0200 Subject: [PATCH] add nsfp tag manager --- public/s/css/f0ckm.css | 126 +++++++++++++++- src/inc/routeinc/f0cklib.mjs | 20 +-- src/inc/routes/apiv2/index.mjs | 2 +- src/inc/routes/nsfp.mjs | 126 ++++++++++++++++ src/inc/settings.mjs | 10 ++ src/index.mjs | 18 ++- views/admin.html | 1 + views/admin/nsfp.html | 254 +++++++++++++++++++++++++++++++++ 8 files changed, 543 insertions(+), 14 deletions(-) create mode 100644 src/inc/routes/nsfp.mjs create mode 100644 views/admin/nsfp.html diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 688d1bd..5762f3d 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -13354,7 +13354,6 @@ i.iconset#a_oc { margin-bottom: 35px; flex-wrap: wrap; gap: 15px; - padding: 0 10px; } @media (max-width: 600px) { @@ -15779,4 +15778,127 @@ body.scroller-active #gchat-reopen-bubble { .notif-thumb.notif-thumb-blurred.revealed::after { opacity: 0; pointer-events: none; -} \ No newline at end of file +} +/* ── NSFP Tag Manager ─────────────────────────────────────────────────────── */ +.nsfp-tag-chip { + display: inline-flex; + align-items: center; + gap: 8px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); + border-radius: 20px; + padding: 6px 14px 6px 12px; + font-size: 0.88em; + font-weight: 600; + color: color-mix(in srgb, var(--accent) 70%, #fff); + transition: background 0.2s, border-color 0.2s; +} +.nsfp-tag-chip:hover { + background: color-mix(in srgb, var(--accent) 20%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); +} +.nsfp-tag-chip .chip-id { + font-size: 0.78em; + opacity: 0.6; + font-weight: 400; + font-family: monospace; +} +.nsfp-tag-chip .chip-remove { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0 0 0 2px; + font-size: 1.1em; + line-height: 1; + opacity: 0.7; + transition: opacity 0.15s; +} +.nsfp-tag-chip .chip-remove:hover { + opacity: 1; + color: #ff4090; +} +.nsfp-chips-area { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 44px; + padding: 10px; + background: rgba(0,0,0,0.2); + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.06); + margin-top: 12px; +} +.nsfp-chips-empty { + color: #888; + font-size: 0.9em; + font-style: italic; + align-self: center; + margin: 0 auto; +} +.nsfp-search-wrap { position: relative; } +.nsfp-search-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + width: 100%; + max-height: 260px; + overflow-y: auto; + background: var(--dropdown-bg, #1a1a1a); + border: 1px solid var(--black, #000); + border-radius: 6px; + box-shadow: 0 6px 20px rgba(0,0,0,0.6); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-color, #555) transparent; +} +.nsfp-search-input { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; + padding: 10px 14px; + border-radius: 6px; + font-size: 1em; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} +.nsfp-search-input:focus { border-color: color-mix(in srgb, var(--accent) 50%, transparent); } +.nsfp-search-input::placeholder { color: rgba(255,255,255,0.35); } +.nsfp-search-result-item { + display: flex; + align-items: center; + padding: 10px 12px; + min-height: 44px; + cursor: pointer; + transition: background 0.12s; + box-sizing: border-box; + user-select: none; +} +.nsfp-search-result-item:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.06); } +.nsfp-search-result-item:hover { background: rgba(255,255,255,0.1); } +.nsfp-search-result-item .result-id { + font-family: monospace; + font-size: 11px; + color: rgba(255,255,255,0.35); + flex-shrink: 0; + margin-right: 4px; +} +.nsfp-search-result-item .result-tag { font-size: 14px; font-weight: 500; color: var(--accent); } +.nsfp-search-result-item .result-norm { font-size: 11px; color: rgba(255,255,255,0.35); margin-left: 12px; white-space: nowrap; } +.nsfp-search-result-item.already-added { opacity: 0.55; cursor: default; } +.nsfp-search-result-item.already-added::after { + content: 'added'; + font-size: 11px; + color: rgba(255,255,255,0.35); + margin-left: auto; + white-space: nowrap; +} +.nsfp-status-msg { + font-size: 0.85em; + font-weight: 600; + margin-top: 8px; + min-height: 18px; + display: block; +} diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index d2a7ac0..1481e93 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -6,7 +6,7 @@ import queue from "../queue.mjs"; import fs from "fs"; import url from "url"; -const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null; +const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null; // All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie) const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime); @@ -251,7 +251,7 @@ export default { ${mimeSQL} ${hallFilter} ${userHallFilter} - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${newerThan ? db`and items.id > ${newerThan}` : db``} ${xdFilter} @@ -293,7 +293,7 @@ export default { ${mimeSQL} ${hallFilter} ${userHallFilter} - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${newerThan ? db`and items.id > ${newerThan}` : db``} ${xdFilter} @@ -515,7 +515,7 @@ export default { ${fav ? db`and "user"."user" ilike ${user}` : db``} ${!fav && user ? db`and items.username ilike ${user}` : db``} ${mimeSQL} - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} `; }; @@ -548,7 +548,7 @@ export default { where items.id = ${itemid} and items.active = true - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} limit 1 `; @@ -566,7 +566,7 @@ export default { if (!actitem) { // Item not found or filtered out - check if it exists but was filtered (for OG meta tags) - if (!session && globalfilter) { + if (!session && getGlobalfilter()) { const unfilteredItem = await db` select id from items where id = ${itemid} and active = true limit 1 `; @@ -917,7 +917,7 @@ export default { AND items.title ILIKE ${'%' + titleQuery + '%'} AND items.title IS NOT NULL ${mimeSQL} - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ORDER BY random() LIMIT 1 @@ -937,7 +937,7 @@ export default { and "user".user ilike ${user} and items.active = true ${mimeSQL} - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} group by items.id order by random() limit 1 @@ -979,7 +979,7 @@ export default { ${user ? db`and items.username ilike ${user}` : db``} ${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``} ${mimeSQL} - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} group by items.id, tags.tag order by random() @@ -998,7 +998,7 @@ export default { and h.slug = ${hall} and items.active = true ${mimeSQL} - ${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} + ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} order by random() limit 1 diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 0d64ed6..55bf543 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -10,7 +10,7 @@ import audit from '../../audit.mjs'; import { parseMultipart, collectBody } from '../../multipart.mjs'; const allowedMimes = ["audio", "image", "video", "%"]; -const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null; +const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null; const metaCache = new Map(); const MAX_META_CACHE = 2000; diff --git a/src/inc/routes/nsfp.mjs b/src/inc/routes/nsfp.mjs new file mode 100644 index 0000000..4f178b3 --- /dev/null +++ b/src/inc/routes/nsfp.mjs @@ -0,0 +1,126 @@ +import db from "../sql.mjs"; +import lib from "../lib.mjs"; +import audit from "../audit.mjs"; +import { getNsfpIds, setNsfpIds } from "../settings.mjs"; + +export default (router, tpl) => { + + // Admin page + router.get(/^\/admin\/nsfp\/?$/, lib.auth, async (req, res) => { + try { + res.reply({ + body: tpl.render("admin/nsfp", { + session: req.session, + totals: await lib.countf0cks(), + csrf_token: req.session?.csrf_token || '' + }, req) + }); + } catch (err) { + console.error('[NSFP] Page render failed:', err); + res.reply({ code: 500, body: 'Internal server error' }); + } + }); + + // API: list current NSFP tag IDs with names + router.get('/api/v2/admin/nsfp', lib.auth, async (req, res) => { + try { + const ids = getNsfpIds(); + const tagRows = ids.length > 0 + ? await db`SELECT id, tag, normalized FROM tags WHERE id IN ${db(ids)} ORDER BY id` + : []; + const tagMap = Object.fromEntries(tagRows.map(r => [r.id, r])); + const enriched = ids.map(id => tagMap[id] || { id, tag: '(unknown)', normalized: null }); + return res.json({ success: true, nsfp: enriched, raw_ids: ids }); + } catch (err) { + return res.json({ success: false, msg: err.message }, 500); + } + }); + + // API: search tags for the add-tag autocomplete + router.get('/api/v2/admin/nsfp/search', lib.auth, async (req, res) => { + try { + const q = (req.url.qs?.q || '').trim(); + if (!q) return res.json({ success: true, tags: [] }); + const pattern = '%' + q + '%'; + const tags = await db` + SELECT id, tag, normalized + FROM tags + WHERE tag ILIKE ${pattern} OR normalized ILIKE ${pattern} + ORDER BY tag + LIMIT 20 + `; + return res.json({ success: true, tags }); + } catch (err) { + return res.json({ success: false, msg: err.message }, 500); + } + }); + + // API: add a tag ID to the NSFP list + router.post('/api/v2/admin/nsfp/add', lib.auth, async (req, res) => { + try { + const tagId = parseInt(req.post?.tag_id); + if (!tagId || isNaN(tagId) || tagId <= 0) { + return res.json({ success: false, msg: 'A valid tag_id is required' }, 400); + } + + const tag = await db`SELECT id, tag FROM tags WHERE id = ${tagId} LIMIT 1`; + if (tag.length === 0) { + return res.json({ success: false, msg: 'Tag with id ' + tagId + ' does not exist' }, 404); + } + + const current = getNsfpIds(); + if (current.includes(tagId)) { + return res.json({ success: false, msg: 'Tag #' + tagId + ' (' + tag[0].tag + ') is already in the NSFP list' }, 409); + } + + const updated = [...current, tagId]; + setNsfpIds(updated); + + await db` + INSERT INTO site_settings (key, value) + VALUES ('nsfp', ${JSON.stringify(updated)}) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `; + + await audit.log(req.session.id, 'nsfp_add', 'tag', tagId, { tag: tag[0].tag, nsfp: updated }); + + return res.json({ success: true, nsfp_ids: getNsfpIds(), added: tag[0] }); + } catch (err) { + console.error('[NSFP] Add failed:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + // API: remove a tag ID from the NSFP list + router.post('/api/v2/admin/nsfp/remove', lib.auth, async (req, res) => { + try { + const tagId = parseInt(req.post?.tag_id); + if (!tagId || isNaN(tagId)) { + return res.json({ success: false, msg: 'tag_id is required' }, 400); + } + + const current = getNsfpIds(); + if (!current.includes(tagId)) { + return res.json({ success: false, msg: 'Tag #' + tagId + ' is not in the NSFP list' }, 404); + } + + const updated = current.filter(id => id !== tagId); + setNsfpIds(updated); + + await db` + INSERT INTO site_settings (key, value) + VALUES ('nsfp', ${JSON.stringify(updated)}) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `; + + await audit.log(req.session.id, 'nsfp_remove', 'tag', tagId, { nsfp: updated }); + + return res.json({ success: true, nsfp_ids: getNsfpIds() }); + } catch (err) { + console.error('[NSFP] Remove failed:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + return router; +}; diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index ad76a92..8c00f9c 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -89,3 +89,13 @@ export const setHashUserIps = (val) => {}; // No-op, strictly config-based export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion; export const setAllowCommentDeletion = (val) => {}; // No-op, strictly config-based +// Live-editable NSFP tag ID list — seeded from config.json, can be overridden by DB setting +let nsfp_ids = Array.isArray(cfg.nsfp) ? [...cfg.nsfp.map(Number).filter(n => !isNaN(n))] : []; + +export const getNsfpIds = () => nsfp_ids; +export const setNsfpIds = (ids) => { + nsfp_ids = Array.isArray(ids) ? ids.map(Number).filter(n => !isNaN(n) && n > 0) : []; + // Also sync to cfg.nsfp so all code reading cfg.nsfp directly still works + cfg.nsfp = [...nsfp_ids]; +}; + diff --git a/src/index.mjs b/src/index.mjs index 6ac296f..8236dc5 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -18,7 +18,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion } from "./inc/settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds } from "./inc/settings.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { createI18n } from "./inc/i18n.mjs"; import security from "./inc/security.mjs"; @@ -1087,6 +1087,22 @@ process.on('uncaughtException', err => { console.warn(`[BOOT] Terms text fetch failed:`, e.message); } + // Fetch nsfp (NSFP tag IDs) setting — overrides config.json if set in DB + try { + const nsfpSetting = await db`SELECT value FROM site_settings WHERE key = 'nsfp' LIMIT 1`; + if (nsfpSetting.length > 0) { + const parsed = JSON.parse(nsfpSetting[0].value); + if (Array.isArray(parsed)) { + setNsfpIds(parsed); + console.log(`[BOOT] NSFP tag IDs loaded from DB: [${getNsfpIds().join(', ')}]`); + } + } else { + console.log(`[BOOT] No NSFP setting in DB, using config.json: [${getNsfpIds().join(', ')}]`); + } + } catch (e) { + console.warn(`[BOOT] NSFP setting fetch failed:`, e.message); + } + const globals = { lul: cfg.websrv.lul, themes: cfg.websrv.themes, diff --git a/views/admin.html b/views/admin.html index 22b1fee..dc3b3db 100644 --- a/views/admin.html +++ b/views/admin.html @@ -21,6 +21,7 @@
  • Hall Manager
  • MOTD Manager
  • Wordfilter Manager
  • +
  • NSFP Tag Manager
  • @if(enable_cleanup)
  • Cleanup Manager
  • @endif diff --git a/views/admin/nsfp.html b/views/admin/nsfp.html new file mode 100644 index 0000000..8671390 --- /dev/null +++ b/views/admin/nsfp.html @@ -0,0 +1,254 @@ +@include(snippets/header) + +
    +
    +
    + +
    +

    NSFP Tag Manager

    +
    +

    + Manage which tags are treated as Not Safe For Public — hidden from logged-out guests. +

    + + +
    +

    Current NSFP Tags

    +
    + Loading… +
    + +
    + + +
    +

    Add Tag to NSFP List

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