From 2cc768f1fde3d67d5fb347b4df7f091cce66d795 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 28 Sep 2026 22:12:38 +0200 Subject: [PATCH] fdsafsad --- config_example.json | 5 + config_example.yaml | 6 + public/s/css/f0ckm.css | 49 ++++--- public/s/js/f0ckm.js | 52 +++---- public/s/js/scroller.js | 5 +- public/s/js/upload.js | 18 ++- src/inc/anon_auth.mjs | 14 +- src/inc/hidden_items.mjs | 30 ++++ src/inc/retention.mjs | 97 +++++++++++++ src/inc/routes/admin.mjs | 2 +- src/inc/routes/apiv2/settings.mjs | 3 +- src/inc/routes/index.mjs | 45 +++++- src/inc/routes/mod.mjs | 3 + src/inc/routes/pending.mjs | 192 +++++++++++++++++++++++++ src/inc/routes/reports.mjs | 2 +- src/inc/routes/static.mjs | 15 ++ src/inc/security.mjs | 16 ++- src/index.mjs | 8 ++ src/upload_handler.mjs | 17 ++- views/pending.html | 168 ++++++++++++++++++++++ views/privacy.html | 188 ++++++++++++++++++++++++ views/snippets/footer.html | 90 ++++++++++-- views/snippets/navbar.html | 9 +- views/snippets/notifications-list.html | 6 +- 24 files changed, 962 insertions(+), 78 deletions(-) create mode 100644 src/inc/hidden_items.mjs create mode 100644 src/inc/retention.mjs create mode 100644 src/inc/routes/pending.mjs create mode 100644 views/pending.html create mode 100644 views/privacy.html diff --git a/config_example.json b/config_example.json index b701699..4dcf378 100644 --- a/config_example.json +++ b/config_example.json @@ -103,6 +103,11 @@ "background": true, "log_user_ips": false, "hash_user_ips": true, + "retention_ip_days": 30, + "retention_activity_log_days": 90, + "retention_login_attempts_days": 30, + "retention_sessions_days": 365, + "retention_fingerprint_days": 365, "description": "Example Description", "themes": [ "amoled" diff --git a/config_example.yaml b/config_example.yaml index 98e9771..3180d99 100644 --- a/config_example.yaml +++ b/config_example.yaml @@ -93,6 +93,12 @@ websrv: background: true log_user_ips: false hash_user_ips: true + # Data retention in days (0 = keep forever). Shown to users on /privacy. + retention_ip_days: 30 # stored IPs: user_ips rows deleted, IP columns elsewhere set to NULL + retention_activity_log_days: 90 # anonymous activity log (actions, IP, fingerprints) + retention_login_attempts_days: 30 # failed/successful login attempts (hashed IP + username) + retention_sessions_days: 365 # sessions unused this long are deleted + retention_fingerprint_days: 365 # device fingerprint of anonymous identities inactive this long description: Example Description themes: - amoled diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index de86180..a7e7f47 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -260,7 +260,7 @@ html[theme='amoled'] { --nav-link-background-linear-gradient: rgba(255, 255, 255, .04), rgba(255, 255, 255, 0); --nav-link-box-shadow: inset 0 0 0 1px rgb(92, 92, 92), inset 0 1px rgb(92, 92, 92), inset 0 -1px rgb(92, 92, 92), 0 1px 1px rgba(92, 92, 92, 0); --nav-link-hover-bg: #6a6a6a70; - --nav-border-color: rgba(255, 255, 255, .05); + --nav-border-color: rgba(255, 255, 255, .20); --dropdown-bg: #232323; --dropdown-item-hover: #0d0d0d; --nav-brand-font: 'VCR'; @@ -2660,11 +2660,12 @@ body.layout-modern .global-sidebar-right { } -/* Edge zone drag handle — simple centered vertical pill */ +/* Edge zone drag handle — pill tucked into the zone's right edge, slides out a little on hover */ #sidebar-drag-zone { display: flex; align-items: center; - justify-content: center; + justify-content: flex-end; + overflow: hidden; top: var(--navbar-h, 50px) !important; opacity: 0; transition: opacity 0.2s ease; @@ -2673,11 +2674,14 @@ body.layout-modern .global-sidebar-right { #sidebar-drag-zone::after { content: ''; display: block; - width: 4px; + flex-shrink: 0; + width: 5px; height: 40px; border-radius: 2px; background: var(--accent, #888); - transition: height 0.2s ease, opacity 0.2s ease; + /* Resting: only a sliver peeks past the edge */ + transform: translateX(3px); + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), height 0.28s cubic-bezier(0.22, 1, 0.36, 1); } #sidebar-drag-zone:hover { @@ -2688,17 +2692,20 @@ body.sidebar-right-hidden #sidebar-drag-zone { opacity: 0.7; } -/* Always show the handle on touch devices (no hover state available) */ +#sidebar-drag-zone:hover::after { + transform: translateX(-3px); + height: 56px; +} + +/* Touch devices: no hover, so the handle stays fully out */ @media (pointer: coarse) { #sidebar-drag-zone { opacity: 0.7; - justify-content: flex-end; - padding-right: 6px; + padding-right: 3px; + } + #sidebar-drag-zone::after { + transform: none; } -} - -#sidebar-drag-zone:hover::after { - height: 56px; } /* Left sidebar edge zone drag handle — mirrors #sidebar-drag-zone */ @@ -11171,7 +11178,7 @@ input#s_avatar { font-size: 0.8em; font-weight: bold; cursor: pointer; - border-radius: 3px; + border-radius: 0; transition: all 0.2s; display: flex; align-items: center; @@ -11344,7 +11351,7 @@ input#s_avatar { background: none; border: 1px solid transparent; min-width: unset; - border-radius: 3px; + border-radius: 0; line-height: 1; opacity: 1; display: flex; @@ -11353,13 +11360,19 @@ input#s_avatar { transition: border-color 0.2s, background 0.2s; } +/* Hover / open state for the dropdown buttons (square, no glow) */ +.nav-user-dropdown:hover > .nav-user-btn, .nav-avatar-btn.is-active { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.15); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-top-right-radius: 0; - border-top-left-radius: 0; + opacity: 1; +} + +.nav-user-dropdown, +.nav-user-dropdown .nav-user-btn, +.nav-user-dropdown .nav-avatar-img, +.nav-user-dropdown .nav-user-menu { + border-radius: 0 !important; } .nav-avatar-btn.is-active .nav-avatar-caret { diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index a2537d9..98f8316 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -2530,7 +2530,7 @@ window.cancelAnimFrame = (function () { document.title = returnTitle; } const returnPath = new URL(returnUrl, window.location.origin).pathname; - const isStaticReturn = returnPath.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/); + const isStaticReturn = returnPath.match(/^\/(about|rules|terms|privacy|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/); if (isStaticReturn && typeof window.loadPageAjax === 'function') { window.loadPageAjax(returnUrl, true, { skipPush: true }); } @@ -2826,7 +2826,7 @@ window.cancelAnimFrame = (function () { e.preventDefault(); const email = document.getElementById('forgot-email').value; const status = document.getElementById('forgot-status'); - const btn = forgotForm.querySelector('button'); + const btn = forgotForm.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = i18n.sending || 'Sending...'; @@ -2868,7 +2868,7 @@ window.cancelAnimFrame = (function () { const password = document.getElementById('reset-password').value; const password_confirm = document.getElementById('reset-password-confirm').value; const status = document.getElementById('reset-status'); - const btn = resetForm.querySelector('button'); + const btn = resetForm.querySelector('button[type="submit"]'); if (password !== password_confirm) { status.className = 'flash-error'; @@ -2917,7 +2917,7 @@ window.cancelAnimFrame = (function () { e.preventDefault(); switchModalView('login'); resetToLogin.style.display = 'none'; - resetForm.querySelector('button').style.display = 'inline-block'; + resetForm.querySelector('button[type="submit"]').style.display = 'inline-block'; }); } } @@ -2945,7 +2945,8 @@ window.cancelAnimFrame = (function () { const formData = new FormData(registerForm); const params = new URLSearchParams(formData); const status = document.getElementById('register-status'); - const btn = registerForm.querySelector('button'); + const btn = registerForm.querySelector('button[type="submit"]'); + const btnLabel = btn.textContent; const password = formData.get('password'); const password_confirm = formData.get('password_confirm'); @@ -3014,7 +3015,7 @@ window.cancelAnimFrame = (function () { } } finally { btn.disabled = false; - btn.textContent = 'Create Account'; + btn.textContent = btnLabel; } }); } @@ -3022,14 +3023,16 @@ window.cancelAnimFrame = (function () { // Switch to register from login // Switch to register from login - const loginToRegister = document.getElementById('login-to-register'); - if (loginToRegister) { - loginToRegister.addEventListener('click', (e) => { + // "Register now" link and the Register button under "Login as Anonymous" + ['login-to-register', 'modal-login-register-btn'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener('click', (e) => { e.preventDefault(); closeModal(loginModal); openModal(registerModal); }); - } + }); // Switch to login from register const registerToLogin = document.getElementById('register-to-login'); @@ -11339,7 +11342,7 @@ window.cancelAnimFrame = (function () { const isAdmin = !!pathname.match(/^\/admin/); const isMod = !!pathname.match(/^\/mod/); const isSettings = pathname.match(/\/settings\/?(?:$|\?)/); - const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes)($|\/|\?)/); + const isStatic = pathname.match(/\/(about|rules|terms|privacy|upload|subscriptions|stats|docs|discord|ranking|meme|memes|pending)($|\/|\?)/); const isUpload = pathname.match(/\/upload\/?(?:$|\?)/); const isItem = typeof isItemPath === 'function' ? isItemPath(pathname) : (!pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(pathname)); const isMessages = !!pathname.match(/^\/messages(\/|$)/); @@ -13583,7 +13586,7 @@ window.cancelAnimFrame = (function () { pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+(?:[?#]|$)/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || - pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || + pathname.match(/^\/(about|rules|terms|privacy|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss') )) || (pathname.startsWith('/4/') && isItemPath(pathname)) @@ -17144,8 +17147,8 @@ window.cancelAnimFrame = (function () { window.addEventListener('resize', syncEdgeZone, { passive: true }); // Returns true if (clientX, clientY) is within the pill's hit area. - // The pill is #sidebar-drag-zone::after — 4×40px, centered on pointer:fine, - // right-aligned with padding-right:6px on pointer:coarse. + // The pill is #sidebar-drag-zone::after — 5×40px, right-aligned at the zone's edge + // (padding-right:3px on pointer:coarse, peeking out on hover for pointer:fine). const isWithinPillArea = (clientX, clientY) => { const rect = edgeZone.getBoundingClientRect(); const pillH = 40; @@ -17153,10 +17156,9 @@ window.cancelAnimFrame = (function () { // Vertical: pill is always centered in the drag zone const pillCenterY = rect.top + rect.height / 2; if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false; - // Horizontal: right-aligned on touch, centered on mouse - const isCoarse = window.matchMedia('(pointer: coarse)').matches; - const pillCenterX = isCoarse ? rect.right - 6 - 2 : rect.left + rect.width / 2; - return clientX >= pillCenterX - 2 - hitPad && clientX <= pillCenterX + 2 + hitPad; + // Horizontal: right-aligned against the zone's edge + const pillCenterX = rect.right - 3 - 2.5; + return clientX >= pillCenterX - 2.5 - hitPad && clientX <= pillCenterX + 2.5 + hitPad; }; // Touchend: tap on the pill toggles sidebar; rest of drag zone only responds to swipe @@ -19050,7 +19052,7 @@ class NotificationSystem { else msg = (window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented'; } - // For admin_pending the thumbnail lives in /mod/pending/t/ until approved + // Pending thumbnails live in /pending/t/ until approved (served to the uploader and to staff only) let thumbSrc, thumbOnerror; if (n.type === 'warning') { return ` @@ -19064,11 +19066,11 @@ class NotificationSystem { `; } else if (n.type === 'admin_pending') { - thumbSrc = `/mod/pending/t/${n.item_id}.webp`; + thumbSrc = `/pending/t/${n.item_id}.webp`; thumbOnerror = `this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}`; } else { thumbSrc = `/t/${n.item_id}.webp`; - thumbOnerror = `this.onerror=null;this.src='/mod/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}`; + thumbOnerror = `this.onerror=null;this.src='/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}`; } const thumb = n.item_id ? `
thumb
` : ''; return ` @@ -19089,7 +19091,7 @@ class NotificationSystem { const link = `/${itemKey}`; return ` -
thumb
+
thumb
${(window.f0ckI18n && window.f0ckI18n.notif_upload_approved) || 'Your Upload has been approved'} @@ -19157,7 +19159,7 @@ class NotificationSystem { const link = '/mod/approve'; return ` -
thumb
+
thumb
${(window.f0ckI18n && window.f0ckI18n.notif_upload_pending) || 'A new upload needs approval'} @@ -19172,7 +19174,7 @@ class NotificationSystem { const link = '/mod/reports'; return ` -
thumb
+
thumb
${(window.f0ckI18n && window.f0ckI18n.notif_new_report) || 'A new user report has been submitted'} @@ -22001,7 +22003,7 @@ document.addEventListener('DOMContentLoaded', () => { const new_password = document.getElementById('force_new_password').value; const new_password_confirm = document.getElementById('force_new_password_confirm').value; const status = document.getElementById('force-password-status'); - const btn = forcePasswordForm.querySelector('button'); + const btn = forcePasswordForm.querySelector('button[type="submit"]'); if (new_password !== new_password_confirm) { status.textContent = 'Passwords do not match.'; diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index ab84d3e..c500917 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -3832,8 +3832,9 @@ if (n.type === 'warning') { thumb = `
`; } else { - const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`; - thumb = n.item_id ? `
` : ''; + // Pending items aren't in /t/ yet: fall back to /pending/t/ (uploader + staff only) + const thumbSrc = n.type === 'admin_pending' ? `/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`; + thumb = n.item_id ? `
` : ''; } return `
${thumb} diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 5ac084b..0f61658 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -7,6 +7,16 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => { .replace(/'/g, "'"); }); +// Manual approval: after an upload, send the uploader to their status page focused on the new item +window.f0ckGoToPending = window.f0ckGoToPending || ((id) => { + const target = '/pending' + (id ? '#i' + id : ''); + if (typeof window.loadPageAjax === 'function') { + window.loadPageAjax(target, false, { bypassCache: true, skipCache: true, skipInherit: true }); + } else { + window.location.href = target; + } +}); + // ============================================================ // URL Upload Tracker — single panel, active jobs only // ============================================================ @@ -3002,6 +3012,8 @@ window.initUploadForm = (selector) => { } } + if (lastData?.manual_approval && lastData?.itemid) window.f0ckGoToPending(lastData.itemid); + // URL uploads: redirect or stay based on user preference (pending/async jobs skip redirect) if (!lastData?.pending && !lastData?.manual_approval) { if (lastData?.itemid && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') { @@ -3416,7 +3428,11 @@ window.initUploadForm = (selector) => { window.location.href = targetUrl; } } - // else: stay on current page (including manual_approval pending) + if (lastData?.manual_approval) { + const pendingItem = lastData || (uploadedResults && uploadedResults[0]); + window.f0ckGoToPending(pendingItem && pendingItem.itemid); + } + // else (redirect disabled): stay on current page } } else { restoreBtn(); diff --git a/src/inc/anon_auth.mjs b/src/inc/anon_auth.mjs index 088f807..b2f99ea 100644 --- a/src/inc/anon_auth.mjs +++ b/src/inc/anon_auth.mjs @@ -4,17 +4,15 @@ import lib from './lib.mjs'; import cfg from './config.mjs'; import security from './security.mjs'; -import { getHashUserIps } from './settings.mjs'; /** - * Get IP for audit/logging, hashed if hash_user_ips is enabled in config. + * IP of the request in its storable form (see security.storableIP): null when IP logging is off. * @param {object} req - * @returns {string} + * @returns {string|null} */ export function resolveAuditIP(req) { - if (!req) return 'unknown'; - const rawIp = security.getRealIP(req); - return getHashUserIps() ? security.hashIP(rawIp) : rawIp; + if (!req) return null; + return security.storableIP(security.getRealIP(req)); } /** @@ -25,7 +23,7 @@ export function resolveAuditIP(req) { export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) { try { const rawIp = security.getRealIP(req); - const ip = getHashUserIps() ? security.hashIP(rawIp) : rawIp; + const ip = security.storableIP(rawIp); const userId = req?.session?.id || null; if (!userId) return; const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null; @@ -157,7 +155,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) { const sessionHash = lib.sha256(session); const csrfToken = crypto.randomBytes(24).toString('hex'); const stamp = ~~(Date.now() / 1e3); - const ip = req?.ip || req?.socket?.remoteAddress || '127.0.0.1'; + const ip = auditIp; const ua = req?.headers ? (req.headers['user-agent'] || '') : ''; const sessRecord = { diff --git a/src/inc/hidden_items.mjs b/src/inc/hidden_items.mjs new file mode 100644 index 0000000..0476065 --- /dev/null +++ b/src/inc/hidden_items.mjs @@ -0,0 +1,30 @@ +import db from "./sql.mjs"; + +/** + * IDs of items that are not live (pending approval, soft-deleted, purged). + * The public media routes (/t/, /ca/) consult this so a leftover file on disk can never + * expose a pending or removed item. Cached briefly because every thumbnail request hits it. + */ + +const TTL_MS = 5000; +let cache = new Set(); +let loadedAt = 0; +let inflight = null; + +const refresh = () => { + if (!inflight) { + inflight = db`select id from items where active = false` + .then(rows => { cache = new Set(rows.map(r => r.id)); loadedAt = Date.now(); }) + .catch(e => { console.warn('[HIDDEN ITEMS] refresh failed:', e.message); }) + .finally(() => { inflight = null; }); + } + return inflight; +}; + +export const isHiddenItem = async (id) => { + if (Date.now() - loadedAt > TTL_MS) await refresh(); + return cache.has(+id); +}; + +// Call after an item changes state (approve / withdraw) so the next lookup reloads +export const invalidateHiddenItems = () => { loadedAt = 0; }; diff --git a/src/inc/retention.mjs b/src/inc/retention.mjs new file mode 100644 index 0000000..880d89d --- /dev/null +++ b/src/inc/retention.mjs @@ -0,0 +1,97 @@ +import db from "./sql.mjs"; +import cfg from "./config.mjs"; + +/** + * retention.mjs — automatic deletion of personal data after a configurable period. + * + * All periods are in days, configured under websrv.* (0 = keep forever): + * retention_ip_days stored IPs: user_ips rows are deleted; IP columns on sessions, anon identities, + * uploads, comments, reports and ToS acceptances are set to NULL + * retention_activity_log_days anon_activity_log rows (action, IP, fingerprints) are deleted + * retention_login_attempts_days login_attempts rows (hashed IP + attempted username) are deleted + * retention_sessions_days sessions unused for this long are deleted (logs that device out) + * retention_fingerprint_days hardware fingerprint is cleared from anonymous identities inactive this long + * + * Active bans (banned_ips / banned_fingerprints / banned_hardware_fingerprints) are not touched: they are kept + * until they expire or are lifted. + */ + +const DEFAULTS = { + ip: 30, + activity_log: 90, + login_attempts: 30, + sessions: 365, + fingerprint: 365 +}; + +const days = (key) => { + const v = cfg.websrv?.[`retention_${key}_days`]; + if (v === undefined || v === null || v === '') return DEFAULTS[key]; + const n = parseInt(v, 10); + return Number.isFinite(n) && n > 0 ? n : 0; +}; + +export const getRetention = () => ({ + ip: days('ip'), + activity_log: days('activity_log'), + login_attempts: days('login_attempts'), + sessions: days('sessions'), + fingerprint: days('fingerprint') +}); + +const RUN_INTERVAL_MS = 60 * 60 * 1000; // hourly + +export const runRetention = async () => { + const r = getRetention(); + const nowSecs = ~~(Date.now() / 1e3); + const before = (d) => new Date(Date.now() - d * 86400e3); + const counts = {}; + const step = async (name, fn) => { + try { + const res = await fn(); + if (res?.count) counts[name] = res.count; + } catch (e) { + console.error(`[RETENTION] ${name} failed:`, e.message); + } + }; + + if (r.ip) { + const cutoff = before(r.ip); + const cutoffSecs = nowSecs - r.ip * 86400; + await step('user_ips', () => db`delete from user_ips where last_seen < ${cutoff}`); + await step('sessions.ip', () => db`update user_sessions set ip = null where ip is not null and last_used < ${cutoffSecs}`); + await step('anon.created_ip', () => db`update anon_identities set created_ip = null where created_ip is not null and created_at < ${cutoff}`); + await step('anon.last_ip', () => db`update anon_identities set last_ip = null where last_ip is not null and last_seen < ${cutoff}`); + await step('items.uploader_ip', () => db`update items set uploader_ip = null where uploader_ip is not null and stamp < ${cutoffSecs}`); + await step('comments.ip', () => db`update comments set ip = null where ip is not null and created_at < ${cutoff}`); + await step('reports.reporter_ip', () => db`update reports set reporter_ip = null where reporter_ip is not null and created_at < ${cutoff}`); + await step('tos.accepted_ip', () => db`update user_tos_acceptance set accepted_ip = null where accepted_ip is not null and accepted_at < ${cutoff}`); + } + + if (r.activity_log) { + await step('anon_activity_log', () => db`delete from anon_activity_log where created_at < ${before(r.activity_log)}`); + } + + if (r.login_attempts) { + await step('login_attempts', () => db`delete from login_attempts where attempted_at < ${before(r.login_attempts)}`); + } + + if (r.sessions) { + await step('user_sessions', () => db`delete from user_sessions where last_used < ${nowSecs - r.sessions * 86400}`); + } + + if (r.fingerprint) { + await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null and last_seen < ${before(r.fingerprint)}`); + } + + const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(', '); + if (summary) console.log(`[RETENTION] Cleaned: ${summary}`); +}; + +export const startRetention = () => { + const r = getRetention(); + const fmt = (d) => d ? `${d}d` : 'forever'; + console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${fmt(r.fingerprint)}`); + setTimeout(runRetention, 30_000); + setInterval(runRetention, RUN_INTERVAL_MS); +}; diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 8b1f5db..31803ef 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -113,7 +113,7 @@ export default (router, tpl) => { last_used: stamp, last_action: "/login", kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0, - ip: ip + ip: security.storableIP(ip) }; await db` diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index c936779..85a2b96 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -1,6 +1,7 @@ import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; +import security from '../../security.mjs'; import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; @@ -1444,7 +1445,7 @@ export default router => { // Create a full user session (same as normal login) const stamp = Math.floor(Date.now() / 1000); - const ip = (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.socket?.remoteAddress || '').split(',')[0].trim(); + const ip = security.storableIP(security.getRealIP(req)); const sessionToken = crypto.randomBytes(32).toString('hex'); const csrfToken = crypto.randomBytes(32).toString('hex'); const sessRecord = { diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 0f4c40f..1baeb3d 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -4,7 +4,8 @@ import lib from "../lib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; import { createI18n } from "../i18n.mjs"; import { render502 } from "../private_items.mjs"; -import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs"; +import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor, getHashUserIps, getLogUserIps, getEnableAnonymousAccess } from "../settings.mjs"; +import { getRetention } from "../retention.mjs"; const auth = async (req, res, next) => { if (!req.session) @@ -689,6 +690,48 @@ export default (router, tpl) => { }); }); + // Everything /privacy states is derived from the running config, so the page can't drift from reality + const privacyState = () => { + const r = getRetention(); + const period = (n) => n ? `${n} day${n === 1 ? '' : 's'}` : 'indefinitely'; + const logIps = getLogUserIps(); + const hashIps = getHashUserIps(); + return { + log_ips: logIps, + hash_ips: hashIps, + ip_mode: !logIps ? 'off' : (hashIps ? 'hashed' : 'raw'), + anon: getEnableAnonymousAccess(), + https: String(cfg.main?.url?.full || '').startsWith('https'), + domain: cfg.main?.url?.domain || '', + ret: { + ip: period(r.ip), + activity: period(r.activity_log), + login: period(r.login_attempts), + sessions: period(r.sessions), + fp: period(r.fingerprint) + }, + ret_on: { + ip: !!r.ip, activity: !!r.activity_log, login: !!r.login_attempts, sessions: !!r.sessions, fp: !!r.fingerprint + } + }; + }; + + router.get(/^\/privacy\/?$/, (req, res) => { + res.reply({ + body: tpl.render('privacy', { + tmp: null, + mail: cfg.main.mail, + pv: privacyState(), + session: (req.session && req.session.user) ? { ...req.session } : false, + page_meta: { + title: 'privacy', + description: 'Privacy: what is stored when you use the site', + url: `https://${cfg.main.url.domain}/privacy` + } + }, req) + }); + }); + router.get(/^\/(rules)$/, (req, res) => { res.reply({ body: tpl.render('rules', { diff --git a/src/inc/routes/mod.mjs b/src/inc/routes/mod.mjs index 0af72b3..0e7e1f3 100644 --- a/src/inc/routes/mod.mjs +++ b/src/inc/routes/mod.mjs @@ -1,6 +1,7 @@ import db from "../sql.mjs"; import lib from "../lib.mjs"; import audit from "../audit.mjs"; +import { invalidateHiddenItems } from "../hidden_items.mjs"; import { promises as fs } from "fs"; import cfg from "../config.mjs"; import fetch from "flumm-fetch"; @@ -317,6 +318,7 @@ export default (router, tpl) => { // We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true. // This prevents duplicate webhooks from double-clicks or race conditions. const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`; + invalidateHiddenItems(); if (result.count === 1) { // Mark pending upload notifications as read for staff @@ -886,6 +888,7 @@ export default (router, tpl) => { const item = rows[0]; const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`; + invalidateHiddenItems(); if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' }); await moveItemFilesToPublic(item, id); diff --git a/src/inc/routes/pending.mjs b/src/inc/routes/pending.mjs new file mode 100644 index 0000000..44ed689 --- /dev/null +++ b/src/inc/routes/pending.mjs @@ -0,0 +1,192 @@ +import db from "../sql.mjs"; +import lib from "../lib.mjs"; +import cfg from "../config.mjs"; +import path from "path"; +import { promises as fs, createReadStream } from "fs"; +import audit from "../audit.mjs"; +import { getManualApproval, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs"; +import { invalidateHiddenItems } from "../hidden_items.mjs"; + +/** + * pending.mjs — upload status for the uploader + * GET /pending own recent uploads with status (pending / live / denied / removed) + * GET /pending/t/:id.webp thumbnail of an own pending upload (pending files are not public) + * POST /pending/delete withdraw an own upload that is still pending (files + row are removed) + * GET /api/v2/uploads/:id/status JSON status for API clients (session or X-Api-Key) + */ + +const RECENT_LIMIT = 50; + +const statusOf = (row) => { + if (row.is_purged) return 'removed'; + if (row.is_deleted) return 'denied'; + if (row.active) return 'live'; + return 'pending'; +}; + +const itemPath = (row) => (getEnableItemSlugs() && row.slug) ? row.slug : row.id; + +// Latest moderator reason for denied/removed items (deny, delete or purge) +const reasonsFor = async (ids) => { + if (!ids.length) return new Map(); + const rows = await db` + select distinct on (target_id) target_id, details->>'reason' as reason + from audit_log + where target_id = any(${ids.map(String)}::text[]) + and action in ('deny_item', 'delete_item', 'purge_item') + order by target_id, created_at desc + `.catch(() => []); + return new Map(rows.map(r => [Number(r.target_id), r.reason])); +}; + +// Session user, or the account behind an X-Api-Key header (for API clients) +const resolveUser = async (req) => { + if (req.session?.id) return req.session; + const key = req.headers['x-api-key']; + if (!key || cfg.websrv.enable_user_api_keys === false) return null; + const rows = await db` + select u.id, u.user, u.login, u.admin, u.is_moderator, u.banned + from user_api_keys k join "user" u on u.id = k.user_id + where k.api_key = ${key} limit 1 + `.catch(() => []); + if (!rows.length || rows[0].banned) return null; + return rows[0]; +}; + +const isOwnerOf = (row, session) => { + const owner = getSessionOwnerName(session); + return !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase()); +}; + +// Every file a pending upload keeps in the pending folder (main file, thumbs, cover art, album sub-items) +const pendingFilesOf = async (row) => { + const p = (...parts) => path.join(cfg.paths.pending, ...parts); + const files = [p('t', `${row.id}.webp`), p('t', `${row.id}_blur.webp`), p('ca', `${row.id}.webp`)]; + if (row.dest && row.mime !== 'video/youtube') files.push(p('b', row.dest)); + if (row.is_album) { + const subs = await db`select dest from album_items where item_id = ${row.id}`.catch(() => []); + for (const sub of subs) { + files.push(p('b', sub.dest), p('t', `${sub.dest.replace(/\.[^.]+$/, '')}.webp`)); + } + } + return files; +}; + +const json = (res, code, obj) => { + const body = JSON.stringify(obj); + return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); +}; + +export default (router, tpl) => { + + router.get(/^\/pending\/?$/, async (req, res) => { + if (!req.session) return res.redirect('/login'); + const owner = getSessionOwnerName(req.session); + + const rows = owner ? await db` + select id, slug, mime, title, original_filename, stamp, size, active, is_deleted, is_purged, is_album, album_count, visibility + from items + where lower(username) = ${owner.toLowerCase()} + order by id desc + limit ${RECENT_LIMIT} + ` : []; + + const reasons = await reasonsFor(rows.filter(r => r.is_deleted).map(r => r.id)); + const items = rows.map(r => { + const status = statusOf(r); + return { + id: r.id, + path: itemPath(r), + status, + title: r.title || r.original_filename || '', + mime: r.mime, + is_album: !!r.is_album, + album_count: r.album_count || 0, + size_fmt: r.size ? lib.formatSize(r.size) : '', + time_ago: r.stamp ? lib.timeAgo(new Date(r.stamp * 1000), req.lang) : '', + time_full: r.stamp ? new Date(r.stamp * 1000).toISOString() : '', + thumb: status === 'live' ? `/t/${r.id}.webp` : (status === 'pending' ? `/pending/t/${r.id}.webp` : ''), + reason: status === 'denied' || status === 'removed' ? (reasons.get(r.id) || '') : '' + }; + }); + + const counts = { pending: 0, live: 0, denied: 0, removed: 0 }; + items.forEach(i => { counts[i.status]++; }); + + res.reply({ + body: tpl.render('pending', { + items, + counts, + manual_approval_on: getManualApproval(), + session: req.session, + tmp: null + }, req) + }); + }); + + // Thumbnail of an own pending upload (moderators may view any) + router.get(/^\/pending\/t\/(?\d+)\.webp$/, async (req, res) => { + if (!req.session) return res.writeHead(401).end(); + const id = +req.params.id; + const [row] = await db`select username, active, is_deleted from items where id = ${id} limit 1`; + const isStaff = !!(req.session.admin || req.session.is_moderator); + const isOwner = isOwnerOf(row, req.session); + if (!row || row.active || row.is_deleted || !(isOwner || isStaff)) return res.writeHead(404).end(); + + const file = path.join(cfg.paths.pending, 't', `${id}.webp`); + try { + const stat = await fs.stat(file); + res.writeHead(200, { 'Content-Type': 'image/webp', 'Content-Length': stat.size, 'Cache-Control': 'private, max-age=60' }); + createReadStream(file).pipe(res); + } catch { + res.writeHead(404).end(); + } + }); + + // Withdraw an own upload before a moderator has looked at it. Only the uploader, only while pending. + router.post(/^\/pending\/delete\/?$/, async (req, res) => { + if (!req.session) return json(res, 401, { success: false, msg: 'Login required' }); + const id = +(req.post?.id || req.body?.id || 0); + if (!id) return json(res, 400, { success: false, msg: 'No ID provided' }); + + const [row] = await db`select id, username, dest, mime, is_album, active, is_deleted, is_purged from items where id = ${id} limit 1`; + if (!row || !isOwnerOf(row, req.session)) return json(res, 404, { success: false, msg: 'Upload not found' }); + if (row.active || row.is_deleted || row.is_purged) return json(res, 409, { success: false, msg: 'Only pending uploads can be deleted' }); + + // Collect files before the row goes (album_items cascade). The active = false guard lets a concurrent + // approval win; files are only touched once the row is gone. Cascades clear tags, notifications, reports. + const files = await pendingFilesOf(row); + const deleted = await db`delete from items where id = ${id} and active = false and is_deleted = false returning id`; + if (!deleted.length) return json(res, 409, { success: false, msg: 'Upload was already reviewed' }); + invalidateHiddenItems(); + await Promise.all(files.map(f => fs.unlink(f).catch(() => {}))); + await audit.log(req.session.id, 'withdraw_item', 'item', id, { filename: row.dest, uploader_name: row.username }); + + return json(res, 200, { success: true, id }); + }); + + // JSON status for API clients (e.g. f0ckm-uploader polling after an upload went to manual approval) + router.get(/^\/api\/v2\/uploads\/(?\d+)\/status\/?$/, async (req, res) => { + const user = await resolveUser(req); + if (!user) return json(res, 401, { success: false, msg: 'Login or X-Api-Key required' }); + + const id = +req.params.id; + const [row] = await db`select id, slug, username, active, is_deleted, is_purged from items where id = ${id} limit 1`; + const owner = getSessionOwnerName(user.is_anon !== undefined ? user : { ...user, is_anon: false }); + const isStaff = !!(user.admin || user.is_moderator); + const isOwner = !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase()); + if (!row || !(isOwner || isStaff)) return json(res, 404, { success: false, msg: 'Upload not found' }); + + const status = statusOf(row); + const reason = (status === 'denied' || status === 'removed') ? ((await reasonsFor([row.id])).get(row.id) || null) : null; + return json(res, 200, { + success: true, + itemid: row.id, + slug: row.slug || null, + status, + reason, + url: status === 'live' ? `${cfg.main.url.full}/${itemPath(row)}` : `${cfg.main.url.full}/pending#i${row.id}`, + status_url: `${cfg.main.url.full}/pending#i${row.id}` + }); + }); +}; diff --git a/src/inc/routes/reports.mjs b/src/inc/routes/reports.mjs index 35f3e51..fe48e31 100644 --- a/src/inc/routes/reports.mjs +++ b/src/inc/routes/reports.mjs @@ -65,7 +65,7 @@ export default (router, tpl) => { INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories) VALUES ( ${req.session ? req.session.id : null}, - ${ip}, + ${security.storableIP(ip)}, ${item_id ? +item_id : null}, ${comment_id ? +comment_id : null}, ${reported_user_id ? +reported_user_id : null}, diff --git a/src/inc/routes/static.mjs b/src/inc/routes/static.mjs index d698fb3..f1695dd 100644 --- a/src/inc/routes/static.mjs +++ b/src/inc/routes/static.mjs @@ -3,6 +3,7 @@ import fs from "fs/promises"; import path from "path"; import db from "../sql.mjs"; import queue from "../queue.mjs"; +import { isHiddenItem } from "../hidden_items.mjs"; export default (router, tpl) => { router.static({ @@ -48,8 +49,21 @@ export default (router, tpl) => { return 'application/octet-stream'; }; + // .webp / _blur.webp belonging to an item that isn't live (pending, deleted) is never public, + // even if a stale file with that name exists. Uploaders use /pending/t/, staff /mod/pending/t/. + const isHiddenMedia = async (file) => { + const m = /^(\d+)(?:_blur)?\.webp$/.exec(file); + return !!m && await isHiddenItem(m[1]); + }; + + const notFound = (res) => { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('404 - file not found.'); + }; + router.get(/^\/t\/(?.+)$/, async (req, res) => { const file = req.params.file; + if (await isHiddenMedia(file)) return notFound(res); const filePath = path.join(cfg.paths.t, file); try { const stat = await fs.stat(filePath); @@ -115,6 +129,7 @@ export default (router, tpl) => { router.get(/^\/ca\/(?.+)$/, async (req, res) => { const file = req.params.file; + if (await isHiddenMedia(file)) return notFound(res); const filePath = path.join(cfg.paths.ca, file); try { const stat = await fs.stat(filePath); diff --git a/src/inc/security.mjs b/src/inc/security.mjs index cf9862b..cb59f12 100644 --- a/src/inc/security.mjs +++ b/src/inc/security.mjs @@ -178,11 +178,19 @@ export default new class { * @param {number} userId * @param {string} ip */ + /** + * The form in which an IP may be written to the database, following the config: + * null when websrv.log_user_ips is off, HMAC-SHA256 when websrv.hash_user_ips is on, raw otherwise. + * Every stored IP (sessions, activity, uploads, comments, reports) goes through here so /privacy stays true. + */ + storableIP(ip) { + if (!cfg.websrv.log_user_ips || !ip || ip === 'unknown') return null; + return cfg.websrv.hash_user_ips ? this.hashIP(ip) : ip; + } + async logUserIP(userId, ip) { - if (!cfg.websrv.log_user_ips || !userId || !ip) return; - - const { getHashUserIps } = await import("./settings.mjs"); - const finalIp = getHashUserIps() ? this.hashIP(ip) : ip; + const finalIp = this.storableIP(ip); + if (!userId || !finalIp) return; await db` insert into user_ips (user_id, ip) diff --git a/src/index.mjs b/src/index.mjs index 932bf4d..9b70549 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -28,6 +28,7 @@ import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs"; import security from "./inc/security.mjs"; import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs"; +import { startRetention } from "./inc/retention.mjs"; import { createRequire } from 'module'; const _require = createRequire(import.meta.url); @@ -615,6 +616,8 @@ process.on('uncaughtException', err => { await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text DEFAULT NULL`); await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text DEFAULT NULL`); await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS uploader_ip character varying(128) DEFAULT NULL`); + // IPs are only stored when websrv.log_user_ips is on (security.storableIP), so activity rows may have none + await runMigration(db`ALTER TABLE anon_activity_log ALTER COLUMN ip DROP NOT NULL`); await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS is_album boolean DEFAULT false`); await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0`); await runMigration(db` @@ -1930,6 +1933,8 @@ process.on('uncaughtException', err => { get manual_approval() { return getManualApproval(); }, get min_tags() { return getMinTags(); }, get registration_open() { return getRegistrationOpen(); }, + // 'off' | 'hashed' | 'raw' — how IPs are persisted (security.storableIP); used for privacy disclosures + get privacy_ip_mode() { return !cfg.websrv.log_user_ips ? 'off' : (cfg.websrv.hash_user_ips ? 'hashed' : 'raw'); }, registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false, registration_require_mail_andor_token: !!cfg.websrv.open_registration_require_mail_andor_token, get trusted_uploads() { return getTrustedUploads(); }, @@ -2307,4 +2312,7 @@ process.on('uncaughtException', err => { setInterval(banInactiveUsers, INACTIVITY_BAN_INTERVAL_MS); } + // ── Data retention — delete / scrub personal data after websrv.retention_*_days (shown on /privacy) + startRetention(); + })(); diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index b622c02..16cd27b 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -284,11 +284,13 @@ export const handleUpload = async (req, res, self) => { const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null; - if (!is_shitpost && !effectiveRating) { + // Admins uploading via API key (ShareX, f0ckm-uploader, …) may skip the rating in every mode; the post is then untagged + const isAdminApiUpload = !!(req.session.api_key_auth && req.session.admin); + if (!is_shitpost && !isAdminApiUpload && !effectiveRating) { return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400); } - if (is_shitpost && cfg.websrv.shitpost_require_rating === true && !effectiveRating) { + if (is_shitpost && !isAdminApiUpload && cfg.websrv.shitpost_require_rating === true && !effectiveRating) { return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required for each item' }, 400); } @@ -556,7 +558,10 @@ export const handleUpload = async (req, res, self) => { visibility: targetVisibility, manual_approval: manualApproval, redirect: !manualApproval ? itemRoute : null, - url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`, + url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/pending#i${itemid}`, + status: manualApproval ? 'pending' : 'live', + status_url: `${cfg.main.url.full}/pending#i${itemid}`, + status_api: `${cfg.main.url.full}/api/v2/uploads/${itemid}/status`, file_url: null, dest: filename, mime: 'video/youtube', @@ -1422,7 +1427,11 @@ export const handleUpload = async (req, res, self) => { visibility: targetVisibility, manual_approval: manualApproval, redirect: !manualApproval ? itemRoute : null, - url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`, + // Pending uploads aren't public yet: point clients at the uploader's status page instead of the homepage + url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/pending#i${itemid}`, + status: manualApproval ? 'pending' : 'live', + status_url: `${cfg.main.url.full}/pending#i${itemid}`, + status_api: `${cfg.main.url.full}/api/v2/uploads/${itemid}/status`, file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null, // Fields for immediate client-side grid injection (avoids SSE race condition) dest: filename, diff --git a/views/pending.html b/views/pending.html new file mode 100644 index 0000000..5f9a56b --- /dev/null +++ b/views/pending.html @@ -0,0 +1,168 @@ +@include(snippets/header) +
+
+
+ +
+
+

My uploads

+
Status of your last {{ items.length }} upload@if(items.length !== 1)s@endif.
+
+
Upload +
+ + @if(manual_approval_on) +
Manual approval is on: new uploads appear on the site once a moderator approves them.
+ @endif + +
+
+
{{ counts.pending }}Pending
+
{{ counts.live }}Live
+
{{ counts.denied + counts.removed }}Denied / removed
+
+
+ +
+

Recent uploads

+ @if(items.length > 0) +
+ @each(items as it) +
+
+ @if(it.thumb) + + @else + + @endif +
+
+
+ @if(it.status === 'live'){!! it.title || ('#' + it.id) !!}@else{!! it.title || ('#' + it.id) !!}@endif + @if(it.is_album) {!! it.album_count !!}@endif +
+
+ #{!! it.id !!} + {!! it.mime !!} + @if(it.size_fmt){!! it.size_fmt !!}@endif + @if(it.time_ago){!! it.time_ago !!}@endif +
+ @if(it.reason) +
Reason: {!! it.reason !!}
+ @endif +
+
+ @if(it.status === 'pending') Pending@endif + @if(it.status === 'live') Live@endif + @if(it.status === 'denied') Denied@endif + @if(it.status === 'removed') Removed@endif +
+
+ @endeach +
+ @else +
You haven't uploaded anything yet.
+ @endif +
+
+ + @include(snippets/adm-dashboard-style) + + +
+
+@include(snippets/footer) diff --git a/views/privacy.html b/views/privacy.html new file mode 100644 index 0000000..ea30e65 --- /dev/null +++ b/views/privacy.html @@ -0,0 +1,188 @@ +@include(snippets/header) +
+
+
+
+

Privacy

+

What this instance stores, in which form, and for how long. This page is generated from the server's running configuration, so the settings below are the ones actually in effect.

+
+ + {{-- ── Live configuration ── --}} +
+

Current settings

+
+
+ IP logging + @if(pv.log_ips)On@elseOff@endif +
+
+ IP storage + @if(pv.ip_mode === 'hashed')Hashed (HMAC-SHA256)@elseif(pv.ip_mode === 'raw')Plain text@elseNot stored@endif +
+
+ Anonymous login + @if(pv.anon)Enabled@elseDisabled@endif +
+
+ Transport + @if(pv.https)HTTPS@elseHTTP@endif +
+
+

+ @if(pv.ip_mode === 'hashed')IP addresses are logged, but only ever written to the database as HMAC-SHA256(ip, server_secret). The raw address is not persisted.@endif + @if(pv.ip_mode === 'raw')IP addresses are logged and written to the database in plain text.@endif + @if(pv.ip_mode === 'off')IP addresses are not written to the database. They are only held in memory while a request is processed (e.g. to check bans and rate limits).@endif +

+
+ + {{-- ── Retention ── --}} +
+

Retention

+

A cleanup job runs hourly and deletes or blanks data older than these periods.

+
+
DataKept forWhat happens after
+
Stored IP addresses{{ pv.ret.ip }}user_ips rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to NULL.
+
Anonymous activity log{{ pv.ret.activity }}Rows deleted (action, IP, identity and device fingerprint).
+
Login attempts{{ pv.ret.login }}Rows deleted (hashed IP, attempted username, result).
+
Unused sessions{{ pv.ret.sessions }}Session deleted after this long without use; that device is logged out.
+
Device fingerprint{{ pv.ret.fp }}Cleared from anonymous identities that haven't been used for this long.
+
Active bansUntil expiryBanned IP hashes and fingerprints are kept until the ban expires or is lifted.
+
Your contentUntil deletedUploads, comments, favourites and your account itself.
+
+
+ + {{-- ── IP addresses ── --}} +
+

IP addresses

+

The client IP is taken from the first of CF-Connecting-IP, True-Client-IP, X-Client-IP, X-Real-IP, X-Forwarded-For (first entry) or the TCP peer address.

+ @if(pv.log_ips) +

With IP logging on, the IP is recorded @if(pv.hash_ips)as an HMAC@else in plain text@endif in:

+
    +
  • user_sessions.ip: updated on each request of a logged-in session
  • +
  • user_ips: one row per account and IP with first/last seen time
  • +
  • anon_identities.created_ip / last_ip and anon_activity_log.ip for anonymous identities
  • +
  • items.uploader_ip, comments.ip, reports.reporter_ip
  • +
+ @endif + @if(pv.hash_ips) +

Hashing: HMAC-SHA256 keyed with a server-side secret, stored as 64 hex characters. The same IP always yields the same hash, which is what makes bans and abuse correlation work; without the secret the hash can't be reversed or recomputed. This is pseudonymisation, not anonymisation: whoever holds the secret could test candidate IPs against it.

+ @endif +

Always, regardless of the logging setting: login and registration attempts store an HMAC of the IP in login_attempts for brute-force rate limiting, and a moderator ban stores the banned IP's hash in banned_ips.

+
+ + @if(pv.anon) + {{-- ── Anonymous login ── --}} +
+

Anonymous login (WebAuthn passkey)

+

No email, password or name is involved. Login as Anonymous creates a standard WebAuthn passkey in your authenticator (browser, OS, Bitwarden, iCloud Keychain, …).

+

Registration

+
    +
  1. The server sends creation options: relying party {{ pv.domain }}, a random single-use challenge, algorithm ES256 (ECDSA P-256, COSE -7), attestation: "none", and a user handle of 16 random bytes named anon@{{ pv.domain }} / "Anonymous". Nothing about you goes into it.
  2. +
  3. Your authenticator generates a key pair. The private key never leaves the authenticator.
  4. +
  5. The server verifies the response and stores: the credential ID, the public key (SPKI), the signature counter, and the authenticator's AAGUID (identifies the authenticator model, e.g. a password manager; with attestation "none" it is often all zeros).
  6. +
  7. Your identity is derived from the credential ID: SHA256:base64(SHA-256(credential_id)); the account name is anon_ plus the first 8 hex characters of that hash (e.g. anon_1ad1e20c).
  8. +
+

Login

+

The server issues a random single-use challenge; your authenticator signs it with the private key and the server verifies the signature with the stored public key. Up to 4 passkeys can be attached to one identity. If all of them are lost, the identity cannot be recovered: nothing else links it to you.

+
+ + {{-- ── Device fingerprint ── --}} +
+

Device fingerprint

+

At anonymous login and when adding a passkey, your browser computes a device fingerprint used only for ban enforcement (so a banned user can't just create a new identity). It is not used for advertising or cross-site tracking.

+

Inputs, all read locally in your browser:

+
    +
  • WebGL: unmasked GPU vendor and renderer, 6 capability limits (max texture/renderbuffer size, vertex attribs, uniform/varying vectors, texture units)
  • +
  • WebGPU adapter info (architecture, vendor, description), where available
  • +
  • navigator.hardwareConcurrency, deviceMemory, platform, maxTouchPoints
  • +
  • Screen width × height, colour depth, device pixel ratio
  • +
  • Canvas 2D: checksum of a small rendered test image (text + shapes)
  • +
  • Audio: sum of samples from an OfflineAudioContext rendering a test tone through a compressor
  • +
+

The values are concatenated and hashed in the browser with SHA-256; only HW:<64 hex> is sent. The raw values never reach the server. The hash is cached in localStorage (f0ck_anon_hw_fp) and stored server-side in anon_identities.hw_fingerprint and the activity log, and compared against banned device hashes.

+
+ + {{-- ── Activity log ── --}} +
+

Anonymous activity log

+

anon_activity_log records these actions of anonymous identities: login/session handshake, favourite, unfavourite, favourites import. Each row holds the action, target item, time, identity fingerprint, device fingerprint, and the IP @if(pv.ip_mode === 'hashed')(hashed)@endif @if(pv.ip_mode === 'off')(empty, as IP logging is off)@endif. It exists for moderation and ban cascades.

+
+ @endif + + {{-- ── Sessions & browser storage ── --}} +
+

Sessions, cookies and browser storage

+
    +
  • session cookie: 32 random bytes; the server stores only its SHA-256, so a database leak doesn't expose usable sessions. Flags: HttpOnly, SameSite=Lax@if(pv.https), Secure@endif.
  • +
  • Per session the server keeps: user agent string, creation time, last-used time, the last path requested, a CSRF token@if(pv.log_ips), and the IP@endif.
  • +
  • localStorage: UI preferences, and for anonymous users the device fingerprint hash.
  • +
  • f0ck_banned cookie / f0ck_anon_tombstone: only set if you are banned, to show the ban notice.
  • +
+

Registered accounts: passwords are hashed with scrypt (random 16-byte salt, 64-byte key). The plain password is never stored.

+
+ +
+

Not collected

+

For anonymous identities: no email, real name, phone number or password. No third-party analytics, trackers or ad networks are involved in authentication.

+
+ +

Questions? See About@if(mail) or write to {!! mail !!}@endif.

+
+ + +
+
+@include(snippets/footer) diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 45b35e7..33cdfdb 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -1,16 +1,89 @@ @if(session) diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html index 08214e2..0a62d1d 100644 --- a/views/snippets/navbar.html +++ b/views/snippets/navbar.html @@ -68,6 +68,7 @@ {{ t('nav.my_halls') }} @endif @endif + Pending Uploads @if(enable_anonymous_access && session.is_anon) Passkey Identity @endif @@ -460,7 +461,10 @@ async function loginWithPasskey() {
or
+
@endif @@ -821,6 +825,9 @@ async function loginWithPasskey() {

You can use it across devices if your passkey manager syncs (e.g. Bitwarden).

+

+ To stop ban evasion we store a hashed device fingerprint@if(privacy_ip_mode === 'hashed') and a hashed IP address@elseif(privacy_ip_mode === 'raw') and your IP address@endif. What exactly is stored? +

diff --git a/views/snippets/notifications-list.html b/views/snippets/notifications-list.html index da66c32..0f48815 100644 --- a/views/snippets/notifications-list.html +++ b/views/snippets/notifications-list.html @@ -2,7 +2,7 @@ @if(n.type === 'approve')
- thumb + thumb