From b10ddac6f3c891bb99a75067ec4b54c35177ec55 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 9 Aug 2026 02:22:22 +0200 Subject: [PATCH] fdfsa --- config_example.json | 1 + migrations/add_expiring_uploads.sql | 3 + package-lock.json | 12 +-- public/s/css/upload.css | 1 + public/s/js/f0ckm.js | 119 +++++++++++++++++++++++++ public/s/js/upload.js | 44 +++++++++ src/inc/lib.mjs | 23 +++++ src/inc/lib_delete.mjs | 43 +++++++++ src/inc/routeinc/f0cklib.mjs | 27 ++++-- src/inc/routes/admin.mjs | 75 +++++++++++++++- src/inc/routes/apiv2/index.mjs | 79 +++++++++++++++- src/inc/routes/apiv2/upload.mjs | 64 +++++++++++-- src/inc/routes/comments.mjs | 12 ++- src/inc/routes/external.mjs | 5 +- src/inc/routes/notifications.mjs | 7 +- src/inc/settings.mjs | 29 ++++++ src/inc/trigger/parser.mjs | 10 ++- src/index.mjs | 45 +++++++++- src/upload_handler.mjs | 22 +++-- views/index-partial.html | 2 +- views/item-partial-legacy.html | 45 ++++++++-- views/item-partial-modern.html | 46 +++++++++- views/ranking.html | 4 +- views/snippets/header.html | 12 +-- views/snippets/items-grid.html | 2 +- views/snippets/subscriptions-grid.html | 4 +- views/snippets/upload-form.html | 15 ++++ views/user-partial.html | 4 +- 28 files changed, 689 insertions(+), 66 deletions(-) create mode 100644 migrations/add_expiring_uploads.sql 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 @@
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 @@
- {{ item.slug || item.id }} — [{!! item.author_display_name || item.username || 'unknown' !!}] @if(item.is_oc) — OC@endif + {{ (enable_item_slugs && item.slug) ? item.slug : item.id }} — [{!! item.author_display_name || item.username || 'unknown' !!}] @if(item.is_oc) — OC@endif @if(halls_enabled && item.primaryHall) — @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 — @endif - + + @if(item.expires_at) + — Expiring ({{ item.expires_in }}) + @endif + +
@if(session) @if(user_has_favorited) @@ -138,7 +143,6 @@ @if(can_manage_item) - @if(is_flash_item) @@ -219,6 +223,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 }} @@ -270,6 +305,9 @@
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)
- +
@if(item.is_pinned) @@ -8,7 +8,7 @@
- #{{ item.slug || item.id }} + #{{ (enable_item_slugs && item.slug) ? item.slug : item.id }} {{ t('subscriptions.by_user').replace('{user}', item.user) }}
diff --git a/views/snippets/upload-form.html b/views/snippets/upload-form.html index 7d9f7bb..f281b2b 100644 --- a/views/snippets/upload-form.html +++ b/views/snippets/upload-form.html @@ -99,6 +99,21 @@
@endif + @if(enable_expiring_uploads !== false) +
+ + +
+ @endif + +