diff --git a/config_example.json b/config_example.json
index 4eca651..b2d9090 100644
--- a/config_example.json
+++ b/config_example.json
@@ -30,6 +30,7 @@
"enable_pdf": false,
"enable_nsfl": false,
"enable_private_uploads": true,
+ "enable_expiring_uploads": true,
"default_upload_visibility": 0,
"allow_user_upload_visibility": true,
"enable_item_slugs": true,
diff --git a/migrations/add_expiring_uploads.sql b/migrations/add_expiring_uploads.sql
new file mode 100644
index 0000000..e8b4742
--- /dev/null
+++ b/migrations/add_expiring_uploads.sql
@@ -0,0 +1,3 @@
+-- Migration: Add expires_at column to items table for expiring uploads
+ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
+CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;
diff --git a/package-lock.json b/package-lock.json
index 234bedf..c25b2ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -75,9 +75,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
- "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -492,9 +492,9 @@
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
},
"brace-expansion": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
- "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"requires": {
"balanced-match": "^1.0.0"
}
diff --git a/public/s/css/upload.css b/public/s/css/upload.css
index 9580d27..57ad862 100644
--- a/public/s/css/upload.css
+++ b/public/s/css/upload.css
@@ -363,6 +363,7 @@
.upload-form.shitpost-mode-active .global-rating-section,
.upload-form.shitpost-mode-active .global-visibility-section,
+.upload-form.shitpost-mode-active .global-expiry-section,
.upload-form.shitpost-mode-active .global-comment-section,
.upload-form.shitpost-mode-active .global-tag-section {
display: none !important;
diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js
index 7068a59..14a4d4c 100644
--- a/public/s/js/f0ckm.js
+++ b/public/s/js/f0ckm.js
@@ -11480,6 +11480,125 @@ document.addEventListener('click', (e) => {
return;
}
+ // Info Modal: Regenerate Thumbnail button
+ const rethumbBtn = e.target.closest('#info-rethumb-btn');
+ if (rethumbBtn) {
+ e.preventDefault();
+ const itemId = rethumbBtn.dataset.itemId || document.getElementById('info-title-input')?.dataset.itemId;
+ if (!itemId) return;
+
+ rethumbBtn.disabled = true;
+ const origText = rethumbBtn.innerHTML;
+ rethumbBtn.innerHTML = ' Regenerating...';
+
+ const csrf = window.f0ckSession?.csrf_token;
+ fetch(`/api/v2/items/${itemId}/rethumb`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrf
+ }
+ })
+ .then(r => r.json())
+ .then(data => {
+ rethumbBtn.disabled = false;
+ rethumbBtn.innerHTML = origText;
+ if (data.success) {
+ if (window.flashMessage) window.flashMessage('THUMBNAIL REGENERATED');
+ else if (window.showFlash) window.showFlash('Thumbnail regenerated');
+ // Cache-bust thumbnails on page
+ const timestamp = Date.now();
+ document.querySelectorAll(`img[src*="/t/${itemId}.webp"]`).forEach(img => {
+ const url = new URL(img.src, window.location.origin);
+ url.searchParams.set('t', timestamp);
+ img.src = url.toString();
+ });
+ document.querySelectorAll(`.thumb[data-bg*="/t/${itemId}.webp"]`).forEach(thumb => {
+ const bg = thumb.dataset.bg;
+ if (bg) {
+ const url = new URL(bg, window.location.origin);
+ url.searchParams.set('t', timestamp);
+ thumb.style.setProperty('--thumb-bg', `url('${url.toString()}')`);
+ }
+ });
+ } else {
+ if (window.flashError) window.flashError(data.msg || 'Regeneration failed');
+ else alert(data.msg || 'Regeneration failed');
+ }
+ })
+ .catch(err => {
+ rethumbBtn.disabled = false;
+ rethumbBtn.innerHTML = origText;
+ console.error('Error regenerating thumbnail:', err);
+ if (window.flashError) window.flashError('Network error');
+ });
+ return;
+ }
+
+ // Info Modal: Set Expiry button
+ const setExpiryBtn = e.target.closest('#info-set-expiry-btn');
+ if (setExpiryBtn) {
+ e.preventDefault();
+ const select = document.getElementById('info-expiry-select');
+ const itemId = setExpiryBtn.dataset.itemId || select?.dataset.itemId || document.getElementById('info-title-input')?.dataset.itemId;
+ if (!itemId || !select) return;
+
+ const selectedExpiry = select.value;
+
+ setExpiryBtn.disabled = true;
+ select.disabled = true;
+ const origText = setExpiryBtn.innerHTML;
+ setExpiryBtn.innerHTML = '';
+
+ const csrf = window.f0ckSession?.csrf_token;
+ fetch(`/api/v2/items/${itemId}/expiry`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrf
+ },
+ body: JSON.stringify({ expiry: selectedExpiry })
+ })
+ .then(r => r.json())
+ .then(data => {
+ setExpiryBtn.disabled = false;
+ select.disabled = false;
+ setExpiryBtn.innerHTML = origText;
+ if (data.success) {
+ const label = document.getElementById('info-expiry-label');
+ if (label) {
+ if (data.expires_at) {
+ label.innerHTML = ` ${data.expires_in || ('in ' + new Date(data.expires_at * 1000).toLocaleString())}`;
+ label.setAttribute('title', new Date(data.expires_at * 1000).toLocaleString());
+ label.style.color = '#ffaa00';
+ } else {
+ label.innerHTML = ' Never';
+ label.removeAttribute('title');
+ label.style.color = '';
+ }
+ }
+ if (data.purged) {
+ if (window.flashMessage) window.flashMessage('ITEM EXPIRED AND PURGED');
+ setTimeout(() => {
+ window.location.href = '/';
+ }, 1200);
+ } else {
+ if (window.flashMessage) window.flashMessage('EXPIRATION UPDATED');
+ }
+ } else {
+ if (window.flashError) window.flashError(data.msg || 'Failed to update expiry');
+ }
+ })
+ .catch(err => {
+ setExpiryBtn.disabled = false;
+ select.disabled = false;
+ setExpiryBtn.innerHTML = origText;
+ console.error('Error setting expiry:', err);
+ if (window.flashError) window.flashError('Network error');
+ });
+ return;
+ }
+
// Close when clicking outside modal content
const infoModal = document.getElementById('info-modal');
if (infoModal && e.target === infoModal) {
diff --git a/public/s/js/upload.js b/public/s/js/upload.js
index be32d6a..35e72df 100644
--- a/public/s/js/upload.js
+++ b/public/s/js/upload.js
@@ -1491,6 +1491,7 @@ window.initUploadForm = (selector) => {
let ratingSwitch = '';
let visibilitySwitch = '';
+ let expirySwitch = '';
let tagsUI = '';
let ocUI = '';
let commentUI = '';
@@ -1543,6 +1544,31 @@ window.initUploadForm = (selector) => {
visibilitySwitch = '';
}
+ const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
+ const hasExpirySection = !!globalExpiryEl || window.f0ckEnableExpiringUploads !== false;
+ if (hasExpirySection && globalExpiryEl) {
+ const globalExpiry = globalExpiryEl.value || 'permanent';
+ const expiryValue = (item.expiry !== undefined && item.expiry !== '') ? item.expiry : globalExpiry;
+ item.expiry = expiryValue;
+
+ expirySwitch = `
+
+
+
+ `;
+ } else {
+ expirySwitch = '';
+ }
+
+
+
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
tagsUI = `
@@ -1587,6 +1613,7 @@ window.initUploadForm = (selector) => {
${titleUI}
${ratingSwitch}
${visibilitySwitch}
+ ${expirySwitch}
${tagsUI}
${commentUI}
`;
@@ -1607,6 +1634,15 @@ window.initUploadForm = (selector) => {
};
});
+ // Handle Expiry
+ const expirySelect = infoRow.querySelector('.item-expiry-select');
+ if (expirySelect) {
+ expirySelect.onchange = () => {
+ item.expiry = expirySelect.value;
+ };
+ }
+
+
// Handle Comment
const commentInput = infoRow.querySelector('.item-comment-input');
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
@@ -2472,6 +2508,9 @@ window.initUploadForm = (selector) => {
try {
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
const visibilityVal = globalVisEl ? globalVisEl.value : '0';
+ const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
+ const expiryVal = globalExpiryEl ? globalExpiryEl.value : 'permanent';
+
const resp = await fetch('/api/v2/upload-url', {
method: 'POST',
headers: {
@@ -2483,6 +2522,7 @@ window.initUploadForm = (selector) => {
url,
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
visibility: visibilityVal,
+ expiry: expiryVal,
tags: tags.join(','),
comment: comment,
is_oc: isOc,
@@ -2590,10 +2630,12 @@ window.initUploadForm = (selector) => {
for (let i = 0; i < selectedFiles.length; i++) {
const item = selectedFiles[i];
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
+ const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
const isUrlItem = isShitpost && item.type === 'url';
const file = !isUrlItem ? (isShitpost ? item.file : item) : null;
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
const fileVisibility = isShitpost ? (item.visibility || globalVisEl?.value || '0') : (globalVisEl?.value || '0');
+ const fileExpiry = isShitpost ? (item.expiry || globalExpiryEl?.value || 'permanent') : (globalExpiryEl?.value || 'permanent');
const fileTags = isShitpost ? item.tags : tags;
const fileComment = isShitpost ? item.comment : comment;
const fileTitle = isShitpost ? (item.title || '') : titleVal;
@@ -2611,6 +2653,7 @@ window.initUploadForm = (selector) => {
}
formData.append('rating', fileRating);
formData.append('visibility', fileVisibility);
+ formData.append('expiry', fileExpiry);
formData.append('tags', fileTags.join(','));
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
if (isShitpost) formData.append('is_shitpost', 'true');
@@ -2663,6 +2706,7 @@ window.initUploadForm = (selector) => {
url: item.url,
rating: fileRating,
visibility: fileVisibility,
+ expiry: fileExpiry,
tags: fileTags.join(','),
is_oc: (isShitpost ? item.is_oc : isOc),
comment: fileComment,
diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs
index 1c31678..1d74d88 100644
--- a/src/inc/lib.mjs
+++ b/src/inc/lib.mjs
@@ -63,6 +63,29 @@ export default new class {
const timeStr = t(unitKey, { n: interval });
return t('timeago.ago', { t: timeStr });
};
+ expiresIn(expiresAt) {
+ if (!expiresAt) return null;
+ const expiresNum = parseInt(expiresAt, 10);
+ if (isNaN(expiresNum)) return null;
+ const now = ~~(Date.now() / 1000);
+ const diff = expiresNum - now;
+ if (diff <= 0) return "expiring now";
+
+ if (diff < 60) return `in ${diff}s`;
+ if (diff < 3600) {
+ const mins = Math.floor(diff / 60);
+ return `in ${mins} minute${mins === 1 ? '' : 's'}`;
+ }
+ if (diff < 86400) {
+ const hours = Math.floor(diff / 3600);
+ const mins = Math.floor((diff % 3600) / 60);
+ return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
+ }
+ const days = Math.floor(diff / 86400);
+ const hours = Math.floor((diff % 86400) / 3600);
+ return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
+ };
+
md5(str) {
return crypto.createHash('md5').update(str).digest("hex");
};
diff --git a/src/inc/lib_delete.mjs b/src/inc/lib_delete.mjs
index 864e141..79acd02 100644
--- a/src/inc/lib_delete.mjs
+++ b/src/inc/lib_delete.mjs
@@ -204,3 +204,46 @@ export async function moveToDeleted(dest, deletedId) {
await fs.unlink(srcPath).catch(() => {});
}
}
+
+/**
+ * Periodically scan for and purge expired uploads (expires_at <= now).
+ * Unlinks media files (thumbnails, coverarts, main image) via safeDeleteMediaFile,
+ * and sets is_deleted = true, is_purged = true, active = false in DB.
+ */
+export async function purgeExpiredUploads() {
+ if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
+ return;
+ }
+ try {
+ const now = ~~(Date.now() / 1000);
+ const expiredItems = await db`
+ SELECT id, dest, mime
+ FROM items
+ WHERE expires_at IS NOT NULL
+ AND expires_at <= ${now}
+ AND is_purged = false
+ `;
+ if (expiredItems.length > 0) {
+ console.log(`[EXPIRING UPLOADS] Found ${expiredItems.length} expired item(s) to purge.`);
+ for (const item of expiredItems) {
+ try {
+ if (item.dest) {
+ 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 && 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}`;
+ console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
+ } catch (e) {
+ console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
+ }
+ }
+ }
+ } catch (err) {
+ console.error('[EXPIRING UPLOADS] Failed running purgeExpiredUploads check:', err);
+ }
+}
+
diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs
index dca6c54..8bc3a0e 100644
--- a/src/inc/routeinc/f0cklib.mjs
+++ b/src/inc/routeinc/f0cklib.mjs
@@ -1,6 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
+import { getEnableItemSlugs } from "../settings.mjs";
import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs";
import fs from "fs";
@@ -599,6 +600,8 @@ export default {
${db.unsafe(modequery)}
and items.active = true
and coalesce(items.visibility, 0) = 0
+ and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
+
${tagFilter}
${titleFilter}
${hallFilter}
@@ -896,6 +899,11 @@ export default {
}
}
+ if (getEnableItemSlugs() && !actitem.slug) {
+ actitem.slug = lib.generateSlug(11);
+ db`UPDATE items SET slug = ${actitem.slug} WHERE id = ${actitem.id} AND (slug IS NULL OR slug = '')`.catch(e => console.error('[AUTO_SLUG] Failed DB update:', e.message));
+ }
+
const data = {
success: true,
user: {
@@ -907,7 +915,7 @@ export default {
},
item: {
id: actitem.id,
- slug: actitem.slug || null,
+ slug: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : null,
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
username: actitem.username,
author_id: actitem.author_id,
@@ -969,15 +977,18 @@ export default {
reposts: repostItems,
width: actitem.width || null,
height: actitem.height || null,
- original_filename: actitem.original_filename || null
+ original_filename: actitem.original_filename || null,
+ expires_at: actitem.expires_at || null,
+ expires_in: lib.expiresIn(actitem.expires_at)
+
},
- title: `${(cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
+ title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
pagination: {
- end: (cfg.enable_item_slugs !== false && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
- start: (cfg.enable_item_slugs !== false && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
- next: (cfg.enable_item_slugs !== false && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
- prev: (cfg.enable_item_slugs !== false && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
- page: (cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id,
+ end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
+ start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
+ next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
+ prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
+ page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
cheat: cheat
},
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs
index b2dfd60..485df50 100644
--- a/src/inc/routes/admin.mjs
+++ b/src/inc/routes/admin.mjs
@@ -12,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, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode } from "../settings.mjs";
+import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -650,7 +650,78 @@ export default (router, tpl) => {
return res.writeHead(302, { "Location": "/admin" }).end();
});
-
+
+ // Config Manager API GET
+ router.get(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
+ const origin = req.headers?.origin || '*';
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
+
+ const isDev = cfg.main?.development === true;
+ if (!isDev && (!req.session || !req.session.admin)) {
+ return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
+ }
+
+ try {
+ const configPath = path.resolve(process.cwd(), "config.json");
+ const raw = await fs.readFile(configPath, "utf-8");
+ const json = JSON.parse(raw);
+ if (res.json) return res.json({ success: true, config: json });
+ return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, config: json }));
+ } catch (err) {
+ if (res.json) return res.json({ success: false, msg: err.message });
+ return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
+ }
+ });
+
+ // Config Manager API POST
+ router.post(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
+ const origin = req.headers?.origin || '*';
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
+
+ const isDev = cfg.main?.development === true;
+ if (!isDev && (!req.session || !req.session.admin)) {
+ return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
+ }
+
+ try {
+ let updatedConfig = req.post?.config || req.json?.config || req.post;
+ if (typeof updatedConfig === 'string') {
+ updatedConfig = JSON.parse(updatedConfig);
+ }
+ if (!updatedConfig || typeof updatedConfig !== 'object') {
+ throw new Error('Invalid configuration payload');
+ }
+
+ const configPath = path.resolve(process.cwd(), "config.json");
+
+ // Write formatted JSON to config.json on disk
+ await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
+
+ // Mutate in-memory cfg object so changes apply immediately
+ Object.assign(cfg, updatedConfig);
+
+ if (getEnableItemSlugs()) {
+ ensureAllItemsHaveSlugs();
+ }
+
+ // Audit log entry (if session available)
+ if (req.session?.id) {
+ await audit.log(req.session.id, 'update_config_file', 'system', 0, { updatedKeys: Object.keys(updatedConfig) });
+ }
+
+ const response = { success: true, message: 'Configuration saved to config.json' };
+ if (res.json) return res.json(response);
+ return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
+ } catch (err) {
+ console.error('[ADMIN] Config Save Error:', err);
+ const response = { success: false, msg: err.message };
+ if (res.json) return res.json(response, 400);
+ return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
+ }
+ });
+
router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => {
if (!getEnableCleanup()) {
return res.redirect("/admin");
diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs
index cb94bfb..c08f62a 100644
--- a/src/inc/routes/apiv2/index.mjs
+++ b/src/inc/routes/apiv2/index.mjs
@@ -2,12 +2,15 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
+import { getEnableItemSlugs } from '../../settings.mjs';
import queue from '../../queue.mjs';
import search from '../../routeinc/search.mjs';
import path from "path";
import f0cklib from '../../routeinc/f0cklib.mjs';
import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs';
+import { purgeExpiredUploads } from '../../lib_delete.mjs';
+import { calculateExpiresAt } from './upload.mjs';
const allowedMimes = ["audio", "image", "video", "%"];
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
@@ -638,7 +641,7 @@ export default router => {
items: {
...safeItem,
id: item.id,
- slug: item.slug || null,
+ slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
dest: relativeDest,
url: directUrl,
direct_url: directUrl
@@ -1229,6 +1232,80 @@ export default router => {
});
});
+ group.post(/\/items\/(?[0-9]+)\/rethumb$/, lib.loggedin, async (req, res) => {
+ const itemid = +req.params.id;
+ if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
+
+ const rows = await db`
+ SELECT id, dest, mime, username
+ FROM items
+ WHERE id = ${itemid} AND active = true AND is_deleted = false
+ LIMIT 1
+ `;
+ if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
+
+ const item = rows[0];
+ const isOwner = item.username === req.session.user;
+ const isAdmin = !!(req.session.admin || req.session.is_moderator);
+ if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
+
+ const ok = await queue.genThumbnail(item.dest, item.mime, item.id, '', false);
+ await queue.genBlurredThumbnail(item.id, false);
+
+ if (ok) {
+ db.notify('rethumb', JSON.stringify({ item_id: item.id })).catch(() => {});
+ audit.log(req.session.id, 'rethumb_item', 'item', item.id, {}).catch(() => {});
+ return res.json({ success: true, itemid: item.id });
+ } else {
+ return res.json({ success: false, msg: 'Thumbnail regeneration failed' }, 500);
+ }
+ });
+
+ group.post(/\/items\/(?[0-9]+)\/expiry$/, lib.loggedin, async (req, res) => {
+ if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
+ return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
+ }
+
+ const itemid = +req.params.id;
+ if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
+
+ const rows = await db`
+ SELECT id, username, expires_at
+ FROM items
+ WHERE id = ${itemid} AND active = true AND is_deleted = false
+ LIMIT 1
+ `;
+ if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
+
+ const item = rows[0];
+ const isOwner = item.username === req.session.user;
+ const isAdmin = !!(req.session.admin || req.session.is_moderator);
+ if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
+
+ const reqExpiry = req.body?.expiry ?? req.body?.expires_at ?? req.post?.expiry ?? req.post?.expires_at;
+ const nowStamp = Math.floor(Date.now() / 1000);
+ const targetExpiresAt = calculateExpiresAt(reqExpiry, nowStamp);
+
+ await db`UPDATE items SET expires_at = ${targetExpiresAt} WHERE id = ${item.id}`;
+ audit.log(req.session.id, 'set_item_expiry', 'item', item.id, { expires_at: targetExpiresAt }).catch(() => {});
+
+ if (targetExpiresAt && targetExpiresAt <= nowStamp) {
+ await purgeExpiredUploads().catch(err => {
+ console.error('[API ITEM EXPIRY] Purge failed:', err);
+ });
+ }
+
+ const expires_in = targetExpiresAt ? lib.expiresIn(targetExpiresAt) : null;
+
+ return res.json({
+ success: true,
+ itemid: item.id,
+ expires_at: targetExpiresAt,
+ expires_in: expires_in,
+ purged: !!(targetExpiresAt && targetExpiresAt <= nowStamp)
+ });
+ });
+
group.post(/\/item\/(?[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs
index db2b273..d6bedaa 100644
--- a/src/inc/routes/apiv2/upload.mjs
+++ b/src/inc/routes/apiv2/upload.mjs
@@ -3,6 +3,7 @@ import { spawn as _spawnRaw } from 'child_process';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
+import { getEnableItemSlugs } from '../../settings.mjs';
import { applyWordFilter } from '../../wordfilter.mjs';
import queue from '../../queue.mjs';
import path from "path";
@@ -160,6 +161,48 @@ const getTargetVisibility = (req, postVis) => {
: sysDefault;
};
+export function calculateExpiresAt(expiryVal, baseStamp = ~~(Date.now() / 1000)) {
+ if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
+ return null;
+ }
+ if (!expiryVal || expiryVal === 'permanent' || expiryVal === 'never' || expiryVal === '0') {
+ return null;
+ }
+ const val = expiryVal.toString().trim().toLowerCase();
+ switch (val) {
+ case '30minutes':
+ case '30m':
+ case '1800':
+ return baseStamp + 1800;
+ case '1hour':
+ case '1h':
+ case '3600':
+ return baseStamp + 3600;
+ case '24hours':
+ case '24h':
+ case '1day':
+ case '86400':
+ return baseStamp + 86400;
+ case '1week':
+ case '1w':
+ case '7days':
+ case '604800':
+ return baseStamp + 604800;
+ case '1month':
+ case '1m':
+ case '30days':
+ case '2592000':
+ return baseStamp + 2592000;
+ default:
+ const parsed = parseInt(val, 10);
+ if (!isNaN(parsed) && parsed > 0) {
+ return parsed > 2000000000 ? parsed : baseStamp + parsed;
+ }
+ return null;
+ }
+}
+
+
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
@@ -393,7 +436,9 @@ export default router => {
const filename = `yt:${videoId}`;
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
- const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
+ const itemSlug = lib.generateSlug(11);
+ const nowStamp = ~~(Date.now() / 1000);
+ const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db`
insert into items ${db({
@@ -406,13 +451,14 @@ export default router => {
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
- stamp: ~~(Date.now() / 1000),
+ stamp: nowStamp,
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
- slug: itemSlug
- }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')}
+ slug: itemSlug,
+ expires_at: targetExpiresAt
+ }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
RETURNING id
`;
@@ -466,7 +512,7 @@ export default router => {
} else {
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
- const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
+ const itemSlug = lib.generateSlug(11);
const session = {
id: req.session.id,
@@ -705,6 +751,7 @@ export default router => {
await fs.unlink(source).catch(() => { });
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
+ const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db`
insert into items ${db({
@@ -717,13 +764,14 @@ export default router => {
username: session.user,
userchannel: 'web',
usernetwork: 'web',
- stamp: ~~(Date.now() / 1000),
+ stamp: nowStamp,
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
- slug: itemSlug
- }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')}
+ slug: itemSlug,
+ expires_at: targetExpiresAt
+ }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
RETURNING id
`;
diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs
index 430be14..15b640b 100644
--- a/src/inc/routes/comments.mjs
+++ b/src/inc/routes/comments.mjs
@@ -1,6 +1,7 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
+import { getEnableItemSlugs } from "../settings.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { promises as fs } from "fs";
@@ -208,6 +209,7 @@ export default (router, tpl) => {
let processedComments = mentionsProcessed.map(c => {
return {
...c,
+ item_slug: getEnableItemSlugs() ? c.item_slug : null,
content: c.content
};
});
@@ -593,7 +595,7 @@ export default (router, tpl) => {
type: 'comment',
id: commentId,
item_id: item_id,
- item_slug: itemQuery[0]?.slug || null,
+ item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
parent_id: parent_id || null,
body: notifyBody,
username: req.session.user,
@@ -1030,9 +1032,15 @@ export default (router, tpl) => {
LIMIT ${limit} OFFSET ${offset}
`;
+ // Normalize item_slug based on getEnableItemSlugs()
+ const processedCommentsList = comments.map(c => ({
+ ...c,
+ item_slug: getEnableItemSlugs() ? c.item_slug : null
+ }));
+
// Fetch comment file attachments
const filesMap = new Map();
- if (comments.length > 0) {
+ if (processedCommentsList.length > 0) {
const commentIds = comments.map(c => c.id);
try {
const files = await db`
diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs
index 8c98555..95ac27e 100644
--- a/src/inc/routes/external.mjs
+++ b/src/inc/routes/external.mjs
@@ -437,8 +437,9 @@ export default (router) => {
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
- original_filename: original_filename || null
- }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
+ original_filename: original_filename || null,
+ slug: lib.generateSlug(11)
+ }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
RETURNING id
`;
diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs
index 3937a36..0b9b950 100644
--- a/src/inc/routes/notifications.mjs
+++ b/src/inc/routes/notifications.mjs
@@ -1,6 +1,7 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
+import { getEnableItemSlugs } from "../settings.mjs";
import { setMotd } from "../motd.mjs";
export const clients = new Set();
@@ -138,7 +139,7 @@ db.listen('activity', async (payload) => {
data.username_color = details.username_color;
data.display_name = details.display_name || null;
data.tag_id = details.tag_id;
- data.item_slug = details.item_slug;
+ data.item_slug = getEnableItemSlugs() ? details.item_slug : null;
} else {
data.username = 'System';
}
@@ -428,7 +429,7 @@ export default (router, tpl) => {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason;
}
- return { ...n, reason };
+ return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
});
return {
@@ -482,7 +483,7 @@ export default (router, tpl) => {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason;
}
- return { ...n, reason };
+ return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
});
return res.reply({
diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs
index f2109ad..588b2cb 100644
--- a/src/inc/settings.mjs
+++ b/src/inc/settings.mjs
@@ -1,4 +1,6 @@
import cfg from "./config.mjs";
+import db from "./sql.mjs";
+import lib from "./lib.mjs";
let manual_approval = true;
let min_tags = 3;
@@ -18,6 +20,33 @@ let cleanup_include_engaged = false;
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
export const setShitpostMode = (val) => {}; // No-op, strictly config-based
+export const getEnableExpiringUploads = () => {
+ if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) return false;
+ return true;
+};
+
+export const getEnableItemSlugs = () => {
+ if (cfg.enable_item_slugs === false || cfg.websrv?.enable_item_slugs === false) return false;
+ return true;
+};
+
+export const ensureAllItemsHaveSlugs = async () => {
+ try {
+ const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
+ if (!rows || rows.length === 0) return;
+ console.log(`[SLUG_BACKFILL] Found ${rows.length} item(s) missing slugs. Backfilling...`);
+ for (const row of rows) {
+ const newSlug = lib.generateSlug(11);
+ await db`UPDATE items SET slug = ${newSlug} WHERE id = ${row.id} AND (slug IS NULL OR slug = '')`;
+ }
+ console.log(`[SLUG_BACKFILL] Successfully backfilled ${rows.length} item slug(s).`);
+ } catch (err) {
+ console.error('[SLUG_BACKFILL] Error during slug backfill:', err.message);
+ }
+};
+
+
+
export const getEnableCleanup = () => {
if (cfg.websrv.enable_cleanup === false) return false;
return enable_cleanup;
diff --git a/src/inc/trigger/parser.mjs b/src/inc/trigger/parser.mjs
index 5bd69a6..303e18b 100644
--- a/src/inc/trigger/parser.mjs
+++ b/src/inc/trigger/parser.mjs
@@ -752,8 +752,9 @@ export default async bot => {
userchannel: e.channel,
usernetwork: e.network,
stamp: ~~(new Date() / 1000),
- active: !getManualApproval()
- }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
+ active: !getManualApproval(),
+ slug: lib.generateSlug(11)
+ }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
`;
const itemid = await queue.getItemID(filename);
@@ -858,8 +859,9 @@ export default async bot => {
userchannel: e.channel,
usernetwork: e.network,
stamp: ~~(new Date() / 1000),
- active: !getManualApproval()
- }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
+ active: !getManualApproval(),
+ slug: lib.generateSlug(11)
+ }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
`;
const itemid = await queue.getItemID(filename);
diff --git a/src/index.mjs b/src/index.mjs
index ab7b592..c26e871 100644
--- a/src/index.mjs
+++ b/src/index.mjs
@@ -20,9 +20,11 @@ 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, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds } 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, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, ensureAllItemsHaveSlugs } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
+import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
+
import security from "./inc/security.mjs";
import { createRequire } from 'module';
@@ -516,6 +518,37 @@ process.on('uncaughtException', err => {
}
});
+ // Global CORS & OPTIONS preflight handler for API routes (enables standalone config_editor.html)
+ app.use(async (req, res) => {
+ if (req.url?.pathname?.startsWith('/api/')) {
+ const origin = req.headers?.origin || '*';
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With, X-CSRF-Token, Authorization');
+
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204).end();
+ req.url.pathname = '/handled_options_bypass';
+ return;
+ }
+ }
+ });
+
+ // Serve standalone config_editor.html statically
+ app.use(async (req, res) => {
+ if (req.method === 'GET' && (req.url?.pathname === '/config_editor.html' || req.url?.pathname === '/config.html')) {
+ try {
+ const filePath = path.resolve(process.cwd(), "config_editor.html");
+ const content = await fs.promises.readFile(filePath, "utf-8");
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(content);
+ req.url.pathname = '/handled_config_editor_bypass';
+ } catch (err) {
+ res.writeHead(404).end("config_editor.html not found");
+ }
+ }
+ });
+
// Cache-Control headers for static assets.
// flummpress router.static() sends no caching headers, which forces Chrome to
// re-fetch all thumbnails on every grid visit even when they haven't changed.
@@ -1330,6 +1363,9 @@ process.on('uncaughtException', err => {
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
}
+ // Ensure all items in database have a unique slug backfilled
+ ensureAllItemsHaveSlugs();
+
const globals = {
lul: cfg.websrv.lul,
themes: cfg.websrv.themes,
@@ -1384,6 +1420,8 @@ process.on('uncaughtException', err => {
site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl,
enable_private_uploads: cfg.enable_private_uploads !== false,
+ get enable_expiring_uploads() { return getEnableExpiringUploads(); },
+ get enable_item_slugs() { return getEnableItemSlugs(); },
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
nsfl_tag_id: cfg.nsfl_tag_id || 3,
@@ -1593,6 +1631,11 @@ process.on('uncaughtException', err => {
setTimeout(cleanupStaleSessions, 30_000);
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
+ // Expiring uploads background purge (every 30s)
+ setTimeout(purgeExpiredUploads, 10_000);
+ setInterval(purgeExpiredUploads, 30_000);
+
+
// ── Inactivity ban — permanently ban accounts that haven't logged in for N days
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0;
diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs
index 5c6f74a..417bdf9 100644
--- a/src/upload_handler.mjs
+++ b/src/upload_handler.mjs
@@ -6,9 +6,11 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import https from "https";
-import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
+import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs";
+import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
+
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
// Adding a new archive type to config.json is sufficient; no code change needed.
@@ -34,6 +36,8 @@ db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text`.catch(() => {});
// One-time migration: add width/height columns for image and video dimension storage
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer`.catch(() => {});
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer`.catch(() => {});
+db`ALTER TABLE items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL`.catch(() => {});
+
// One-time migration: widen checksum column to varchar(255) for SHA-256 + bypass suffix support
// (old schema had varchar(40), sized for SHA-1 — SHA-256 is 64 chars and bypass suffix adds more)
@@ -172,8 +176,13 @@ export const handleUpload = async (req, res, self) => {
}
}
- // Generate slug if enabled
- const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
+ const rawExpiry = req.headers['x-upload-expiry'] || parts.expiry || parts.expires_at;
+ const nowStamp = ~~(Date.now() / 1000);
+ const targetExpiresAt = calculateExpiresAt(rawExpiry, nowStamp);
+
+
+ // Always generate a unique item slug for the database
+ const itemSlug = lib.generateSlug(11);
const maxLen = cfg.main.comment_max_length;
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
@@ -487,7 +496,7 @@ export const handleUpload = async (req, res, self) => {
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
- stamp: ~~(Date.now() / 1000),
+ stamp: nowStamp,
active: !manualApproval,
is_oc: is_oc,
original_filename: originalFilename,
@@ -495,8 +504,9 @@ export const handleUpload = async (req, res, self) => {
width: itemWidth,
height: itemHeight,
visibility: targetVisibility,
- slug: itemSlug
- }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug')}
+ slug: itemSlug,
+ expires_at: targetExpiresAt
+ }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
`;
const itemid = await queue.getItemID(filename);
diff --git a/views/index-partial.html b/views/index-partial.html
index e47ca14..9441f1f 100644
--- a/views/index-partial.html
+++ b/views/index-partial.html
@@ -3,7 +3,7 @@
@include(snippets/page-title)
@each(items as item)
-
+
@if(item.is_pinned)
diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html
index 49a5af5..7e21624 100644
--- a/views/item-partial-legacy.html
+++ b/views/item-partial-legacy.html
@@ -6,7 +6,7 @@
-
{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}
+
{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}
@if(enable_item_title)
@@ -102,7 +102,7 @@
-
+
@@ -125,13 +125,15 @@
- {{ item.slug || item.id }}
+ {{ (enable_item_slugs && item.slug) ? item.slug : item.id }}
@if(!user_alternative_infobox) — [{!! item.author_display_name || item.username || 'unknown' !!}] @endif
@if(!user_alternative_infobox)
@if(item.is_oc) — OC@endif
@endif
- @if(!user_alternative_infobox) —
@if(halls_enabled && item.primaryHall) — @endif @endif
+ @if(!user_alternative_infobox) —
@if(item.expires_at) —
Expiring ({{ item.expires_in }})@endif@if(halls_enabled && item.primaryHall) — @endif @endif
+
+
@if(halls_enabled && item.primaryHall)
{{ item.primaryHall.name }}@if(item.otherHalls && item.otherHalls.length)+{{ item.otherHalls.length }}@each(item.otherHalls as oh){{ oh.name }}@endeach@endif
@@ -155,7 +157,6 @@
@endif
@if(can_manage_item)
-
@if(can_extract_meta)
@@ -277,6 +278,37 @@
@endif
+ @if(enable_expiring_uploads !== false)
+
+ | Expires |
+
+
+
+ @if(item.expires_at)
+ {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
+ @else
+ Never
+ @endif
+
+ @if(can_manage_item)
+
+
+
+
+ @endif
+
+ |
+
+ @endif
+
+
| {{ t('info_modal.file_size') || 'File Size' }} |
{{ item.size }} |
@@ -328,6 +360,9 @@
+ @if(can_manage_item)
+
+ @endif
diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html
index 501d4f3..74b4b7f 100644
--- a/views/item-partial-modern.html
+++ b/views/item-partial-modern.html
@@ -58,7 +58,7 @@
-
{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}
+
{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}
@if(enable_item_title)
@@ -119,14 +119,19 @@
+ @if(enable_expiring_uploads !== false)
+
+ | Expires |
+
+
+
+ @if(item.expires_at)
+ {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
+ @else
+ Never
+ @endif
+
+ @if(can_manage_item)
+
+
+
+
+ @endif
+
+ |
+
+ @endif
+
+
| {{ t('info_modal.file_size') || 'File Size' }} |
{{ item.size }} |
@@ -270,6 +305,9 @@
+ @if(can_manage_item)
+
+ @endif
diff --git a/views/ranking.html b/views/ranking.html
index 02d8cfd..6cd9279 100644
--- a/views/ranking.html
+++ b/views/ranking.html
@@ -94,7 +94,7 @@
@each(favotop as favo)
- | #{{ favo.slug || favo.id }} |
+ #{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }} |
{{ favo.favs }} {{ t('ranking.favs') }} |
@endeach
@@ -109,7 +109,7 @@
@each(xdtop as item)
- | #{{ item.slug || item.id }} |
+ #{{ (enable_item_slugs && item.slug) ? item.slug : item.id }} |
{{ item.xd_label }} {{ item.xd_score }}
diff --git a/views/snippets/header.html b/views/snippets/header.html
index d4d1bc2..383952c 100644
--- a/views/snippets/header.html
+++ b/views/snippets/header.html
@@ -2,7 +2,7 @@
- @if(typeof page_meta !== 'undefined' && page_meta.title){{ domain }} - {{ page_meta.title }}@elseif(typeof item !== 'undefined'){{ domain }} - {{ item.slug || item.id }}@else{{ domain }}@endif
+ @if(typeof page_meta !== 'undefined' && page_meta.title){{ domain }} - {{ page_meta.title }}@elseif(typeof item !== 'undefined'){{ domain }} - {{ (enable_item_slugs && item.slug) ? item.slug : item.id }}@else{{ domain }}@endif
@@ -72,18 +72,18 @@
@if(typeof item !== 'undefined')
-
+
-
-
+
+
-
+
-
+
@else
diff --git a/views/snippets/items-grid.html b/views/snippets/items-grid.html
index e2bbf77..6616356 100644
--- a/views/snippets/items-grid.html
+++ b/views/snippets/items-grid.html
@@ -1,5 +1,5 @@
@each(items as item)
-
+
@if(item.is_pinned)
diff --git a/views/snippets/subscriptions-grid.html b/views/snippets/subscriptions-grid.html
index 04b81c3..6cc9d15 100644
--- a/views/snippets/subscriptions-grid.html
+++ b/views/snippets/subscriptions-grid.html
@@ -1,6 +1,6 @@
@each(items as item)
@endif
+ @if(enable_expiring_uploads !== false)
+
+
+
+
+ @endif
+
+
|