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 User IPs
-
Log all historical IPs for user accounts.
+
Enable Cleanup Action
+
Enable the automated cleanup system and manager.
-
-
-
-
-
-
-
Hash User IPs
-
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.
+
+
+
+
+
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)