diff --git a/config_example.json b/config_example.json index e2f3a13..f7e28a4 100644 --- a/config_example.json +++ b/config_example.json @@ -58,6 +58,8 @@ "enable_userhall_image_upload": true, "abyss_enabled": true, "meme_creator": true, + "enable_cleanup": false, + "cleanup_timeframe_days": 30, "web_url_upload": true, "enable_youtube_upload": true, diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 4238b31..5b1284c 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -1,6 +1,7 @@ import db from "../sql.mjs"; import audit from "../audit.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; +import { safeDeleteMediaFile } from "../lib_delete.mjs"; import lib from "../lib.mjs"; import { setMotd } from "../motd.mjs"; @@ -11,7 +12,7 @@ import cfg from "../config.mjs"; import security from "../security.mjs"; import crypto from "crypto"; import path from "path"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps } from "../settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate } from "../settings.mjs"; export default (router, tpl) => { router.get(/^\/login(\/)?$/, async (req, res) => { @@ -283,6 +284,8 @@ export default (router, tpl) => { manual_approval: getManualApproval(), log_user_ips: getLogUserIps(), hash_user_ips: getHashUserIps(), + enable_cleanup: getEnableCleanup(), + enable_cleanup_config: cfg.websrv.enable_cleanup !== false, tmp: null }, req) }); @@ -612,14 +615,12 @@ export default (router, tpl) => { router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => { const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false'; const registration_open = req.post.registration_open === 'on' ? 'true' : 'false'; - const log_user_ips = req.post.log_user_ips === 'on' ? 'true' : 'false'; - const hash_user_ips = req.post.hash_user_ips === 'on' ? 'true' : 'false'; + const enable_cleanup = req.post.enable_cleanup === 'on' ? 'true' : 'false'; const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags)); const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3); await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; - await db`INSERT INTO site_settings (key, value) VALUES ('log_user_ips', ${log_user_ips}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; - await db`INSERT INTO site_settings (key, value) VALUES ('hash_user_ips', ${hash_user_ips}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; + await db`INSERT INTO site_settings (key, value) VALUES ('enable_cleanup', ${enable_cleanup}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; if (cfg.websrv.open_registration_web_toggle !== false) { await db`INSERT INTO site_settings (key, value) VALUES ('registration_open', ${registration_open}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; @@ -630,8 +631,7 @@ export default (router, tpl) => { await db`INSERT INTO site_settings (key, value) VALUES ('trusted_uploads', ${trusted_uploads.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setManualApproval(manual_approval === 'true'); - setLogUserIps(log_user_ips === 'true'); - setHashUserIps(hash_user_ips === 'true'); + setEnableCleanup(enable_cleanup === 'true'); setMinTags(min_tags); setTrustedUploads(trusted_uploads); @@ -650,6 +650,160 @@ export default (router, tpl) => { return res.writeHead(302, { "Location": "/admin" }).end(); }); + + router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => { + if (!getEnableCleanup()) { + return res.redirect("/admin"); + } + + const cleanup_payload = { + session: req.session, + enable_cleanup: getEnableCleanup(), + cleanup_start_date: getCleanupStartDate(), + cleanup_end_date: getCleanupEndDate(), + totals: await lib.countf0cks(), + tmp: null + }; + + res.reply({ + body: tpl.render("admin/cleanup", cleanup_payload, req) + }); + }); + + router.post(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => { + try { + const enable_cleanup = req.post.enable_cleanup === 'on' ? 'true' : 'false'; + const cleanup_start_date = req.post.cleanup_start_date || ''; + const cleanup_end_date = req.post.cleanup_end_date || ''; + + await db`INSERT INTO site_settings (key, value) VALUES ('enable_cleanup', ${enable_cleanup}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; + await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; + await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; + + setEnableCleanup(enable_cleanup === 'true'); + setCleanupStartDate(cleanup_start_date); + setCleanupEndDate(cleanup_end_date); + + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() }); + return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); + } + + return res.writeHead(302, { "Location": "/admin/cleanup" }).end(); + } catch (err) { + console.error('[ADMIN] Cleanup Settings Save failed:', err); + const msg = 'Failed to save Cleanup settings: ' + err.message; + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + const body = JSON.stringify({ success: false, msg }); + return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); + } + return res.reply({ code: 500, body: msg }); + } + }); + + router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => { + try { + // Ensure settings are synced from DB before execution + const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`; + const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value])); + + const isEnabled = settingsMap['enable_cleanup'] === 'true'; + const startDate = settingsMap['cleanup_start_date'] || ''; + const endDate = settingsMap['cleanup_end_date'] || ''; + + // Update memory state + setEnableCleanup(isEnabled); + setCleanupStartDate(startDate); + setCleanupEndDate(endDate); + + if (!isEnabled) { + throw new Error('Cleanup is disabled in settings.'); + } + + if (!startDate || !endDate) { + throw new Error('Please select both a Start Date and an End Date.'); + } + + console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`); + + const start_stamp = ~~(new Date(startDate).getTime() / 1000); + const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day + + // Diagnostics: Count candidates + const totalCleanable = await db`SELECT count(*) as c FROM items WHERE is_purged = false AND is_pinned = false`; + const withinRange = await db`SELECT count(*) as c FROM items WHERE is_purged = false AND is_pinned = false AND stamp >= ${start_stamp} AND stamp <= ${end_stamp}`; + + const oldItems = await db` + SELECT i.id, i.dest, i.mime + FROM items i + WHERE i.is_purged = false + AND i.is_pinned = false + AND i.stamp >= ${start_stamp} + AND i.stamp <= ${end_stamp} + AND ( + -- Case 1: Active posts with no engagement (ignoring automatic subscriptions) + (i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id)) + OR + -- Case 2: Soft-deleted posts (trash) that are old enough to be purged + (i.is_deleted = true) + OR + -- Case 3: Pending posts (not yet approved) that are old enough + (i.active = false AND i.is_deleted = false) + ) + `; + + const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`; + console.log(`[ADMIN] Cleanup diagnostic: found ${oldItems.length} targets. ${statsInfo}`); + + let count = 0; + if (oldItems.length > 0) { + for (const item of oldItems) { + try { + await safeDeleteMediaFile(item.dest, item.id); + await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { }); + await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => { }); + + if (item.mime?.startsWith('audio')) { + await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { }); + } + + await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`; + count++; + } catch (e) { + console.error(`[CLEANUP] Failed to delete item ${item.id}:`, e.message); + } + } + } + + // Log it in audit + await audit.log(req.session.id, 'run_cleanup_manual', 'system', 0, { count, startDate, endDate }); + + const response = { success: true, count, msg: `Successfully cleaned up ${count} posts. ${statsInfo}` }; + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + const body = JSON.stringify(response); + return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); + } + + const cleanup_payload = { + ...response, + session: req.session, + enable_cleanup: getEnableCleanup(), + cleanup_start_date: getCleanupStartDate(), + cleanup_end_date: getCleanupEndDate(), + totals: await lib.countf0cks() + }; + + return res.reply({ body: tpl.render("admin/cleanup", cleanup_payload, req) }); + } catch (err) { + console.error('[ADMIN] Cleanup execution failed:', err); + const msg = 'Cleanup failed: ' + err.message; + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + const body = JSON.stringify({ success: false, msg }); + return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); + } + return res.reply({ code: 500, body: msg }); + } + }); // User Management Routes router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => { diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index 3437139..250d761 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -9,6 +9,21 @@ let protect_files = false; let private_messages = true; let default_layout = 'modern'; let enable_pdf = false; +let enable_cleanup = false; +let cleanup_start_date = ''; +let cleanup_end_date = ''; + +export const getEnableCleanup = () => { + if (cfg.websrv.enable_cleanup === false) return false; + return enable_cleanup; +}; +export const setEnableCleanup = (val) => enable_cleanup = !!val; + +export const getCleanupStartDate = () => cleanup_start_date; +export const setCleanupStartDate = (val) => cleanup_start_date = val || ''; + +export const getCleanupEndDate = () => cleanup_end_date; +export const setCleanupEndDate = (val) => cleanup_end_date = val || ''; export const getEnablePdf = () => enable_pdf; export const setEnablePdf = (val) => enable_pdf = !!val; @@ -45,17 +60,8 @@ export const setPrivateMessages = (val) => private_messages = !!val; export const getDefaultLayout = () => default_layout; export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern'); -let log_user_ips = false; -export const getLogUserIps = () => log_user_ips; -export const setLogUserIps = (val) => { - log_user_ips = !!val; - // Also update the config object for components that read from it directly - cfg.websrv.log_user_ips = log_user_ips; -}; +export const getLogUserIps = () => !!cfg.websrv.log_user_ips; +export const setLogUserIps = (val) => {}; // No-op, strictly config-based -let hash_user_ips = false; -export const getHashUserIps = () => hash_user_ips; -export const setHashUserIps = (val) => { - hash_user_ips = !!val; - cfg.websrv.hash_user_ips = hash_user_ips; -}; +export const getHashUserIps = () => !!cfg.websrv.hash_user_ips; +export const setHashUserIps = (val) => {}; // No-op, strictly config-based diff --git a/src/index.mjs b/src/index.mjs index 607de2b..5c773e1 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -16,7 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs"; import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs"; import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf } from "./inc/settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps } from "./inc/settings.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { createI18n } from "./inc/i18n.mjs"; import security from "./inc/security.mjs"; @@ -664,28 +664,34 @@ process.on('uncaughtException', err => { setEnablePdf(!!cfg.enable_pdf); console.log(`[BOOT] Enable PDF setting: ${getEnablePdf()}`); - // Fetch log_user_ips and hash_user_ips setting - const { getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps } = await import("./inc/settings.mjs"); - try { - const lipSetting = await db`SELECT value FROM site_settings WHERE key = 'log_user_ips' LIMIT 1`; - if (lipSetting.length > 0) { - setLogUserIps(lipSetting[0].value === 'true'); - } else { - setLogUserIps(!!cfg.websrv.log_user_ips); - } - console.log(`[BOOT] Log User IPs: ${getLogUserIps()}`); + // IP logging settings are strictly config-based + console.log(`[BOOT] Log User IPs: ${getLogUserIps()}`); + console.log(`[BOOT] Hash User IPs: ${getHashUserIps()}`); - const hipSetting = await db`SELECT value FROM site_settings WHERE key = 'hash_user_ips' LIMIT 1`; - if (hipSetting.length > 0) { - setHashUserIps(hipSetting[0].value === 'true'); + // Fetch enable_cleanup, cleanup_start_date, and cleanup_end_date setting + try { + const ecSetting = await db`SELECT value FROM site_settings WHERE key = 'enable_cleanup' LIMIT 1`; + if (ecSetting.length > 0) { + setEnableCleanup(ecSetting[0].value === 'true'); } else { - setHashUserIps(!!cfg.websrv.hash_user_ips); + setEnableCleanup(!!cfg.websrv.enable_cleanup); } - console.log(`[BOOT] Hash User IPs: ${getHashUserIps()}`); + console.log(`[BOOT] Enable Cleanup: ${getEnableCleanup()}`); + + const startSetting = await db`SELECT value FROM site_settings WHERE key = 'cleanup_start_date' LIMIT 1`; + if (startSetting.length > 0) { + setCleanupStartDate(startSetting[0].value); + } + console.log(`[BOOT] Cleanup Start Date: ${getCleanupStartDate()}`); + + const endSetting = await db`SELECT value FROM site_settings WHERE key = 'cleanup_end_date' LIMIT 1`; + if (endSetting.length > 0) { + setCleanupEndDate(endSetting[0].value); + } + console.log(`[BOOT] Cleanup End Date: ${getCleanupEndDate()}`); } catch (e) { - console.warn(`[BOOT] IP logging settings fetch failed:`, e.message); - setLogUserIps(!!cfg.websrv.log_user_ips); - setHashUserIps(!!cfg.websrv.hash_user_ips); + console.warn(`[BOOT] Cleanup settings fetch failed:`, e.message); + setEnableCleanup(!!cfg.websrv.enable_cleanup); } // Load bypass_duplicate_check from config.json (static — not a DB setting) @@ -790,6 +796,9 @@ process.on('uncaughtException', err => { enable_profile_description: !!cfg.websrv.enable_profile_description, get private_messages() { return getPrivateMessages(); }, get enable_pdf() { return getEnablePdf(); }, + get enable_cleanup() { return getEnableCleanup(); }, + get cleanup_start_date() { return getCleanupStartDate(); }, + get cleanup_end_date() { return getCleanupEndDate(); }, matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false, ts: Date.now(), get default_layout() { return getDefaultLayout(); }, diff --git a/views/admin.html b/views/admin.html index 86ba84a..174eb6f 100644 --- a/views/admin.html +++ b/views/admin.html @@ -20,6 +20,9 @@
  • Meme Manager
  • Hall Manager
  • MOTD Manager
  • + @if(enable_cleanup) +
  • Cleanup Manager
  • + @endif
  • About Page
  • Rules Page
  • ToS Page
  • @@ -40,26 +43,19 @@ + + @if(typeof enable_cleanup_config === "undefined" || enable_cleanup_config !== false)
    - -

    Log all historical IPs for user accounts.

    + +

    Enable the automated cleanup system and manager.

    -
    -
    -
    - -

    Anonymize IPs by hashing them (same as failed logins).

    -
    -
    + @endif @if(registration_web_toggle_enabled)
    @@ -119,8 +115,7 @@ const status = document.getElementById('settings-status'); const approvalToggle = document.getElementById('manual_approval_toggle'); const registrationToggle = document.getElementById('registration_open_toggle'); - const logIpsToggle = document.getElementById('log_user_ips_toggle'); - const hashIpsToggle = document.getElementById('hash_user_ips_toggle'); + const cleanupToggle = document.getElementById('enable_cleanup_toggle'); const minTagsInput = document.getElementById('min_tags_input'); const trustedUploadsInput = document.getElementById('trusted_uploads_input'); @@ -136,8 +131,7 @@ }, body: new URLSearchParams({ manual_approval: approvalToggle.checked ? 'on' : 'off', - log_user_ips: logIpsToggle.checked ? 'on' : 'off', - hash_user_ips: hashIpsToggle.checked ? 'on' : 'off', + enable_cleanup: cleanupToggle.checked ? 'on' : 'off', ...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}), min_tags: minTagsInput.value, trusted_uploads: trustedUploadsInput.value, diff --git a/views/admin/cleanup.html b/views/admin/cleanup.html new file mode 100644 index 0000000..1fd5e62 --- /dev/null +++ b/views/admin/cleanup.html @@ -0,0 +1,155 @@ +@include(snippets/header) +
    +
    +
    +

    Cleanup Manager

    +

    Delete old posts that have no engagement (no comments, no favorites, and no subscriptions) to regain disk space.

    + +
    +
    + + +
    +
    + +

    This must be enabled to allow running the manual cleanup process.

    +
    + +
    + +
    + +
    +
    + From: + +
    +
    + To: + +
    + Posts uploaded within this period with no engagement will be targeted. +
    +
    + +
    + + +
    +
    +
    + +
    +

    Execute Cleanup

    +

    Click the button below to start the cleanup process based on the timeframe saved above.

    + +
    + +
    +
    +
    + +
    +

    Danger Zone

    +

    Deletions performed by the cleanup manager are permanent and cannot be undone. Files will be removed from disk to regain space. Only posts with 0 comments, 0 favorites, and 0 subscriptions are targeted.

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