fdfsa
This commit is contained in:
@@ -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,
|
||||
|
||||
3
migrations/add_expiring_uploads.sql
Normal file
3
migrations/add_expiring_uploads.sql
Normal file
@@ -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;
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '<i class="fa-solid fa-spinner fa-spin"></i> 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 = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
||||
|
||||
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 = `<i class="fa-solid fa-clock"></i> ${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 = '<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> 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) {
|
||||
|
||||
@@ -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 = `
|
||||
<div class="item-expiry-container" style="margin-top: 4px;">
|
||||
<select class="item-expiry-select" style="width: 100%; padding: 4px 8px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 4px; font-size: 0.85em; cursor: pointer;">
|
||||
<option value="permanent" ${expiryValue === 'permanent' ? 'selected' : ''}>Permanent (Never expires)</option>
|
||||
<option value="30minutes" ${expiryValue === '30minutes' ? 'selected' : ''}>30 Minutes</option>
|
||||
<option value="1hour" ${expiryValue === '1hour' ? 'selected' : ''}>1 Hour</option>
|
||||
<option value="24hours" ${expiryValue === '24hours' ? 'selected' : ''}>24 Hours</option>
|
||||
<option value="1week" ${expiryValue === '1week' ? 'selected' : ''}>1 Week</option>
|
||||
<option value="1month" ${expiryValue === '1month' ? 'selected' : ''}>1 Month</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
} 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,
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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) => {
|
||||
@@ -651,6 +651,77 @@ 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");
|
||||
|
||||
@@ -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\/(?<id>[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\/(?<id>[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\/(?<id>[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);
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@include(snippets/page-title)
|
||||
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
||||
@each(items as item)
|
||||
<a href="{{ link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<div class="_204863">
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}</div>
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
@@ -102,7 +102,7 @@
|
||||
<div class="user-infobox-username-container">
|
||||
<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="user-infobox-username">{!! item.author_display_name || item.username !!}</a>
|
||||
</div>
|
||||
<span class="user-infobox-timestamp"><a href="/{{ item.slug || item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||
<span class="user-infobox-timestamp"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||
</div>
|
||||
<div class="user-infobox-body">
|
||||
<div class="user-infobox-description">
|
||||
@@ -125,13 +125,15 @@
|
||||
|
||||
<span class="badge badge-dark">
|
||||
|
||||
<a href="/{{ item.slug || item.id }}" class="id-link" data-item-id="{{ item.id }}" @if(user_alternative_infobox)style="display:none"@endif>{{ item.slug || item.id }}</a>
|
||||
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}" @if(user_alternative_infobox)style="display:none"@endif>{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a>
|
||||
@if(!user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
|
||||
@if(!user_alternative_infobox)
|
||||
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
@endif
|
||||
</span>
|
||||
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ item.slug || item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(halls_enabled && item.primaryHall) — @endif @endif
|
||||
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(item.expires_at) — <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>@endif@if(halls_enabled && item.primaryHall) — @endif @endif
|
||||
|
||||
|
||||
@if(halls_enabled && item.primaryHall)
|
||||
<span class="badge hall-badge-wrap">
|
||||
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
||||
@@ -155,7 +157,6 @@
|
||||
<i class="iconset fa-solid fa-layer-group" id="a_hall" data-item-id="{{ item.id }}" data-halls="{{ halls_slugs }}" data-user-halls="{{ user_halls_slugs }}" data-current-hall="{{ (tmp.hall && typeof tmp.hall === 'object') ? tmp.hall.slug : (tmp.hall || '') }}" data-current-user-hall="{{ (tmp.userHall && typeof tmp.userHall === 'object') ? tmp.userHall.slug : (tmp.userHall || '') }}" data-current-user-hall-owner="{{ tmp.userHallOwner || '' }}" title="Add to Hall"></i>
|
||||
@endif
|
||||
@if(can_manage_item)
|
||||
<i class="iconset fa-solid fa-eye" id="a_visibility" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="Visibility: {{ item.visibility === 2 ? 'Private' : (item.visibility === 1 ? 'Unlisted' : 'Public') }} (Click to change)"></i>
|
||||
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
|
||||
@if(can_extract_meta)
|
||||
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
|
||||
@@ -277,6 +278,37 @@
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<tr class="info-expiry-row">
|
||||
<th>Expires</th>
|
||||
<td>
|
||||
<div class="info-expiry-wrap" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
<span id="info-expiry-label" style="display: inline-flex; align-items: center; gap: 6px; color: #ffaa00;" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||
@if(item.expires_at)
|
||||
<i class="fa-solid fa-clock"></i> {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
|
||||
@else
|
||||
<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Never
|
||||
@endif
|
||||
</span>
|
||||
@if(can_manage_item)
|
||||
<div class="info-expiry-edit-controls" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<select id="info-expiry-select" class="info-expiry-select" style="padding: 2px 6px; font-size: 0.85em; background: rgba(0,0,0,0.4); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px;" data-item-id="{{ item.id }}">
|
||||
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent (Never)</option>
|
||||
<option value="30minutes">30 Minutes</option>
|
||||
<option value="1hour">1 Hour</option>
|
||||
<option value="24hours">24 Hours</option>
|
||||
<option value="1week">1 Week</option>
|
||||
<option value="1month">1 Month</option>
|
||||
</select>
|
||||
<button id="info-set-expiry-btn" class="btn-secondary btn-sm" style="padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}"><i class="fa-solid fa-check"></i> Save</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
|
||||
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
@@ -328,6 +360,9 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
@if(can_manage_item)
|
||||
<button class="btn-secondary" id="info-rethumb-btn" data-item-id="{{ item.id }}"><i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail</button>
|
||||
@endif
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<div class="_204863">
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}</div>
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
@@ -119,14 +119,19 @@
|
||||
</div>
|
||||
<div class="blahlol">
|
||||
<span class="badge badge-dark">
|
||||
<a href="/{{ item.slug || item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ item.slug || item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
</span>@if(halls_enabled && item.primaryHall) — @endif
|
||||
@if(halls_enabled && item.primaryHall)
|
||||
<span class="badge hall-badge-wrap">
|
||||
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
||||
</span> —
|
||||
@endif
|
||||
<span class="badge badge-dark"><a href="/{{ item.slug || item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||
<span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||
@if(item.expires_at)
|
||||
— <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>
|
||||
@endif
|
||||
|
||||
|
||||
<div class="gapRight">
|
||||
@if(session)
|
||||
@if(user_has_favorited)
|
||||
@@ -138,7 +143,6 @@
|
||||
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
|
||||
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
|
||||
@if(can_manage_item)
|
||||
<i class="iconset fa-solid fa-eye" id="a_visibility" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="Visibility: {{ item.visibility === 2 ? 'Private' : (item.visibility === 1 ? 'Unlisted' : 'Public') }} (Click to change)"></i>
|
||||
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
|
||||
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
|
||||
@if(is_flash_item)
|
||||
@@ -219,6 +223,37 @@
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<tr class="info-expiry-row">
|
||||
<th>Expires</th>
|
||||
<td>
|
||||
<div class="info-expiry-wrap" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
<span id="info-expiry-label" style="display: inline-flex; align-items: center; gap: 6px; color: #ffaa00;" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||
@if(item.expires_at)
|
||||
<i class="fa-solid fa-clock"></i> {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
|
||||
@else
|
||||
<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Never
|
||||
@endif
|
||||
</span>
|
||||
@if(can_manage_item)
|
||||
<div class="info-expiry-edit-controls" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<select id="info-expiry-select" class="info-expiry-select" style="padding: 2px 6px; font-size: 0.85em; background: rgba(0,0,0,0.4); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px;" data-item-id="{{ item.id }}">
|
||||
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent (Never)</option>
|
||||
<option value="30minutes">30 Minutes</option>
|
||||
<option value="1hour">1 Hour</option>
|
||||
<option value="24hours">24 Hours</option>
|
||||
<option value="1week">1 Week</option>
|
||||
<option value="1month">1 Month</option>
|
||||
</select>
|
||||
<button id="info-set-expiry-btn" class="btn-secondary btn-sm" style="padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}"><i class="fa-solid fa-check"></i> Save</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
|
||||
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
@@ -270,6 +305,9 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
@if(can_manage_item)
|
||||
<button class="btn-secondary" id="info-rethumb-btn" data-item-id="{{ item.id }}"><i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail</button>
|
||||
@endif
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<tbody>
|
||||
@each(favotop as favo)
|
||||
<tr>
|
||||
<td><a href="/{{ favo.slug || favo.id }}">#{{ favo.slug || favo.id }}</a></td>
|
||||
<td><a href="/{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}">#{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}</a></td>
|
||||
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
|
||||
</tr>
|
||||
@endeach
|
||||
@@ -109,7 +109,7 @@
|
||||
<tbody>
|
||||
@each(xdtop as item)
|
||||
<tr>
|
||||
<td><a href="/{{ item.slug || item.id }}">#{{ item.slug || item.id }}</a></td>
|
||||
<td><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a></td>
|
||||
<td>
|
||||
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
|
||||
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
|
||||
|
||||
<head>
|
||||
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ item.slug || item.id }}</title>@else<title>{{ domain }}</title>@endif
|
||||
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</title>@else<title>{{ domain }}</title>@endif
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0096ff">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -72,18 +72,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
@if(typeof item !== 'undefined')
|
||||
<link rel="canonical" href="https://{{ domain }}/{{ item.slug || item.id }}" />
|
||||
<link rel="canonical" href="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="og:site_name" content="{{ domain }}" />
|
||||
<meta property="og:title" content="{{ item.slug || item.id }}" />
|
||||
<meta property="og:url" content="https://{{ domain }}/{{ item.slug || item.id }}" />
|
||||
<meta property="og:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="og:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||
<meta name="description" content="{{ site_description }}" />
|
||||
<meta property="og:description" content="{{ site_description }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="twitter:card" content="summary" />
|
||||
<meta property="twitter:title" content="{{ item.slug || item.id }}" />
|
||||
<meta property="twitter:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||
<meta property="twitter:url" content="https://{{ domain }}/{{ item.slug || item.id }}" />
|
||||
<meta property="twitter:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
@else
|
||||
<meta property="og:site_name" content="{{ domain }}" />
|
||||
<meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@each(items as item)
|
||||
<a href="{{ link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@each(items as item)
|
||||
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
|
||||
<a href="/{{ item.slug || item.id }}" class="sub-link">
|
||||
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="sub-link">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<img src="{{ item.thumb }}" loading="lazy" />
|
||||
<div class="sub-info">
|
||||
<span class="sub-id">#{{ item.slug || item.id }}</span>
|
||||
<span class="sub-id">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</span>
|
||||
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -99,6 +99,21 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<div class="form-section global-expiry-section">
|
||||
<label><i class="fa-solid fa-clock"></i> Expiration / Self-Destruct</label>
|
||||
<select name="expiry" class="expiry-select" style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 6px; cursor: pointer; font-size: 0.95em;">
|
||||
<option value="permanent" selected>Permanent (Never expires)</option>
|
||||
<option value="30minutes">30 Minutes</option>
|
||||
<option value="1hour">1 Hour</option>
|
||||
<option value="24hours">24 Hours</option>
|
||||
<option value="1week">1 Week</option>
|
||||
<option value="1month">1 Month</option>
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
|
||||
<div class="form-section global-tag-section">
|
||||
<label>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
@if(count.f0cks)
|
||||
<div class="posts no-infinite-scroll">
|
||||
@each(f0cks.items as item)
|
||||
<a href="{{ f0cks.link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
||||
<a href="{{ f0cks.link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
@@ -156,7 +156,7 @@
|
||||
@if(count.favs)
|
||||
<div class="posts no-infinite-scroll">
|
||||
@each(favs.items as item)
|
||||
<a href="{{ favs.link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
||||
<a href="{{ favs.link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
Reference in New Issue
Block a user