This commit is contained in:
2026-09-28 22:12:38 +02:00
parent b96f188a59
commit 2cc768f1fd
24 changed files with 962 additions and 78 deletions
+5
View File
@@ -103,6 +103,11 @@
"background": true, "background": true,
"log_user_ips": false, "log_user_ips": false,
"hash_user_ips": true, "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", "description": "Example Description",
"themes": [ "themes": [
"amoled" "amoled"
+6
View File
@@ -93,6 +93,12 @@ websrv:
background: true background: true
log_user_ips: false log_user_ips: false
hash_user_ips: true 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 description: Example Description
themes: themes:
- amoled - amoled
+31 -18
View File
@@ -260,7 +260,7 @@ html[theme='amoled'] {
--nav-link-background-linear-gradient: rgba(255, 255, 255, .04), rgba(255, 255, 255, 0); --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-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-link-hover-bg: #6a6a6a70;
--nav-border-color: rgba(255, 255, 255, .05); --nav-border-color: rgba(255, 255, 255, .20);
--dropdown-bg: #232323; --dropdown-bg: #232323;
--dropdown-item-hover: #0d0d0d; --dropdown-item-hover: #0d0d0d;
--nav-brand-font: 'VCR'; --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 { #sidebar-drag-zone {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-end;
overflow: hidden;
top: var(--navbar-h, 50px) !important; top: var(--navbar-h, 50px) !important;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
@@ -2673,11 +2674,14 @@ body.layout-modern .global-sidebar-right {
#sidebar-drag-zone::after { #sidebar-drag-zone::after {
content: ''; content: '';
display: block; display: block;
width: 4px; flex-shrink: 0;
width: 5px;
height: 40px; height: 40px;
border-radius: 2px; border-radius: 2px;
background: var(--accent, #888); 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 { #sidebar-drag-zone:hover {
@@ -2688,17 +2692,20 @@ body.sidebar-right-hidden #sidebar-drag-zone {
opacity: 0.7; 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) { @media (pointer: coarse) {
#sidebar-drag-zone { #sidebar-drag-zone {
opacity: 0.7; opacity: 0.7;
justify-content: flex-end; padding-right: 3px;
padding-right: 6px; }
#sidebar-drag-zone::after {
transform: none;
} }
}
#sidebar-drag-zone:hover::after {
height: 56px;
} }
/* Left sidebar edge zone drag handle — mirrors #sidebar-drag-zone */ /* Left sidebar edge zone drag handle — mirrors #sidebar-drag-zone */
@@ -11171,7 +11178,7 @@ input#s_avatar {
font-size: 0.8em; font-size: 0.8em;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 0;
transition: all 0.2s; transition: all 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -11344,7 +11351,7 @@ input#s_avatar {
background: none; background: none;
border: 1px solid transparent; border: 1px solid transparent;
min-width: unset; min-width: unset;
border-radius: 3px; border-radius: 0;
line-height: 1; line-height: 1;
opacity: 1; opacity: 1;
display: flex; display: flex;
@@ -11353,13 +11360,19 @@ input#s_avatar {
transition: border-color 0.2s, background 0.2s; 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 { .nav-avatar-btn.is-active {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.15);
border-bottom-left-radius: 0; opacity: 1;
border-bottom-right-radius: 0; }
border-top-right-radius: 0;
border-top-left-radius: 0; .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 { .nav-avatar-btn.is-active .nav-avatar-caret {
+27 -25
View File
@@ -2530,7 +2530,7 @@ window.cancelAnimFrame = (function () {
document.title = returnTitle; document.title = returnTitle;
} }
const returnPath = new URL(returnUrl, window.location.origin).pathname; 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') { if (isStaticReturn && typeof window.loadPageAjax === 'function') {
window.loadPageAjax(returnUrl, true, { skipPush: true }); window.loadPageAjax(returnUrl, true, { skipPush: true });
} }
@@ -2826,7 +2826,7 @@ window.cancelAnimFrame = (function () {
e.preventDefault(); e.preventDefault();
const email = document.getElementById('forgot-email').value; const email = document.getElementById('forgot-email').value;
const status = document.getElementById('forgot-status'); const status = document.getElementById('forgot-status');
const btn = forgotForm.querySelector('button'); const btn = forgotForm.querySelector('button[type="submit"]');
btn.disabled = true; btn.disabled = true;
btn.textContent = i18n.sending || 'Sending...'; btn.textContent = i18n.sending || 'Sending...';
@@ -2868,7 +2868,7 @@ window.cancelAnimFrame = (function () {
const password = document.getElementById('reset-password').value; const password = document.getElementById('reset-password').value;
const password_confirm = document.getElementById('reset-password-confirm').value; const password_confirm = document.getElementById('reset-password-confirm').value;
const status = document.getElementById('reset-status'); const status = document.getElementById('reset-status');
const btn = resetForm.querySelector('button'); const btn = resetForm.querySelector('button[type="submit"]');
if (password !== password_confirm) { if (password !== password_confirm) {
status.className = 'flash-error'; status.className = 'flash-error';
@@ -2917,7 +2917,7 @@ window.cancelAnimFrame = (function () {
e.preventDefault(); e.preventDefault();
switchModalView('login'); switchModalView('login');
resetToLogin.style.display = 'none'; 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 formData = new FormData(registerForm);
const params = new URLSearchParams(formData); const params = new URLSearchParams(formData);
const status = document.getElementById('register-status'); 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 = formData.get('password');
const password_confirm = formData.get('password_confirm'); const password_confirm = formData.get('password_confirm');
@@ -3014,7 +3015,7 @@ window.cancelAnimFrame = (function () {
} }
} finally { } finally {
btn.disabled = false; 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
// Switch to register from login // Switch to register from login
const loginToRegister = document.getElementById('login-to-register'); // "Register now" link and the Register button under "Login as Anonymous"
if (loginToRegister) { ['login-to-register', 'modal-login-register-btn'].forEach(id => {
loginToRegister.addEventListener('click', (e) => { const el = document.getElementById(id);
if (!el) return;
el.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
closeModal(loginModal); closeModal(loginModal);
openModal(registerModal); openModal(registerModal);
}); });
} });
// Switch to login from register // Switch to login from register
const registerToLogin = document.getElementById('register-to-login'); const registerToLogin = document.getElementById('register-to-login');
@@ -11339,7 +11342,7 @@ window.cancelAnimFrame = (function () {
const isAdmin = !!pathname.match(/^\/admin/); const isAdmin = !!pathname.match(/^\/admin/);
const isMod = !!pathname.match(/^\/mod/); const isMod = !!pathname.match(/^\/mod/);
const isSettings = pathname.match(/\/settings\/?(?:$|\?)/); 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 isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
const isItem = typeof isItemPath === 'function' ? isItemPath(pathname) : (!pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(pathname)); const isItem = typeof isItemPath === 'function' ? isItemPath(pathname) : (!pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(pathname));
const isMessages = !!pathname.match(/^\/messages(\/|$)/); const isMessages = !!pathname.match(/^\/messages(\/|$)/);
@@ -13583,7 +13586,7 @@ window.cancelAnimFrame = (function () {
pathname.match(/\/p\/\d+/) || pathname.match(/\/p\/\d+/) ||
pathname.match(/^\/\d+(?:[?#]|$)/) || pathname.match(/^\/\d+(?:[?#]|$)/) ||
pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || 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('/abyss')
)) || )) ||
(pathname.startsWith('/4/') && isItemPath(pathname)) (pathname.startsWith('/4/') && isItemPath(pathname))
@@ -17144,8 +17147,8 @@ window.cancelAnimFrame = (function () {
window.addEventListener('resize', syncEdgeZone, { passive: true }); window.addEventListener('resize', syncEdgeZone, { passive: true });
// Returns true if (clientX, clientY) is within the pill's hit area. // 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, // The pill is #sidebar-drag-zone::after — 5×40px, right-aligned at the zone's edge
// right-aligned with padding-right:6px on pointer:coarse. // (padding-right:3px on pointer:coarse, peeking out on hover for pointer:fine).
const isWithinPillArea = (clientX, clientY) => { const isWithinPillArea = (clientX, clientY) => {
const rect = edgeZone.getBoundingClientRect(); const rect = edgeZone.getBoundingClientRect();
const pillH = 40; const pillH = 40;
@@ -17153,10 +17156,9 @@ window.cancelAnimFrame = (function () {
// Vertical: pill is always centered in the drag zone // Vertical: pill is always centered in the drag zone
const pillCenterY = rect.top + rect.height / 2; const pillCenterY = rect.top + rect.height / 2;
if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false; if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false;
// Horizontal: right-aligned on touch, centered on mouse // Horizontal: right-aligned against the zone's edge
const isCoarse = window.matchMedia('(pointer: coarse)').matches; const pillCenterX = rect.right - 3 - 2.5;
const pillCenterX = isCoarse ? rect.right - 6 - 2 : rect.left + rect.width / 2; return clientX >= pillCenterX - 2.5 - hitPad && clientX <= pillCenterX + 2.5 + hitPad;
return clientX >= pillCenterX - 2 - hitPad && clientX <= pillCenterX + 2 + hitPad;
}; };
// Touchend: tap on the pill toggles sidebar; rest of drag zone only responds to swipe // 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'; 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; let thumbSrc, thumbOnerror;
if (n.type === 'warning') { if (n.type === 'warning') {
return ` return `
@@ -19064,11 +19066,11 @@ class NotificationSystem {
</div> </div>
`; `;
} else if (n.type === 'admin_pending') { } 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';}`; thumbOnerror = `this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}`;
} else { } else {
thumbSrc = `/t/${n.item_id}.webp`; 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 ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="${thumbSrc}" alt="thumb" onerror="${thumbOnerror}"></div>` : ''; const thumb = n.item_id ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="${thumbSrc}" alt="thumb" onerror="${thumbOnerror}"></div>` : '';
return ` return `
@@ -19089,7 +19091,7 @@ class NotificationSystem {
const link = `/${itemKey}`; const link = `/${itemKey}`;
return ` return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="${n.item_id || ''}" data-item-slug="${n.item_slug || n.slug || ''}"> <a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="${n.item_id || ''}" data-item-slug="${n.item_slug || n.slug || ''}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="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';};}"></div> <div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="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';};}"></div>
<div class="notif-content"> <div class="notif-content">
<div> <div>
<strong>${(window.f0ckI18n && window.f0ckI18n.notif_upload_approved) || 'Your Upload has been approved'}</strong> <strong>${(window.f0ckI18n && window.f0ckI18n.notif_upload_approved) || 'Your Upload has been approved'}</strong>
@@ -19157,7 +19159,7 @@ class NotificationSystem {
const link = '/mod/approve'; const link = '/mod/approve';
return ` return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="${n.item_id || ''}" data-item-slug="${n.item_slug || n.slug || ''}"> <a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="${n.item_id || ''}" data-item-slug="${n.item_slug || n.slug || ''}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/pending/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div> <div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/pending/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
<div class="notif-content"> <div class="notif-content">
<div> <div>
<strong>${(window.f0ckI18n && window.f0ckI18n.notif_upload_pending) || 'A new upload needs approval'}</strong> <strong>${(window.f0ckI18n && window.f0ckI18n.notif_upload_pending) || 'A new upload needs approval'}</strong>
@@ -19172,7 +19174,7 @@ class NotificationSystem {
const link = '/mod/reports'; const link = '/mod/reports';
return ` return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="${n.item_id || ''}" data-item-slug="${n.item_slug || n.slug || ''}"> <a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="${n.item_id || ''}" data-item-slug="${n.item_slug || n.slug || ''}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="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';};}"></div> <div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="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';};}"></div>
<div class="notif-content"> <div class="notif-content">
<div> <div>
<strong>${(window.f0ckI18n && window.f0ckI18n.notif_new_report) || 'A new user report has been submitted'}</strong> <strong>${(window.f0ckI18n && window.f0ckI18n.notif_new_report) || 'A new user report has been submitted'}</strong>
@@ -22001,7 +22003,7 @@ document.addEventListener('DOMContentLoaded', () => {
const new_password = document.getElementById('force_new_password').value; const new_password = document.getElementById('force_new_password').value;
const new_password_confirm = document.getElementById('force_new_password_confirm').value; const new_password_confirm = document.getElementById('force_new_password_confirm').value;
const status = document.getElementById('force-password-status'); 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) { if (new_password !== new_password_confirm) {
status.textContent = 'Passwords do not match.'; status.textContent = 'Passwords do not match.';
+3 -2
View File
@@ -3832,8 +3832,9 @@
if (n.type === 'warning') { if (n.type === 'warning') {
thumb = `<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>`; thumb = `<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>`;
} else { } else {
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`; // Pending items aren't in /t/ yet: fall back to /pending/t/ (uploader + staff only)
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : ''; const thumbSrc = n.type === 'admin_pending' ? `/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='/pending/t/${n.item_id}.webp'"></div>` : '';
} }
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}"> return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
${thumb} ${thumb}
+17 -1
View File
@@ -7,6 +7,16 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
}); });
// 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 // 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) // URL uploads: redirect or stay based on user preference (pending/async jobs skip redirect)
if (!lastData?.pending && !lastData?.manual_approval) { if (!lastData?.pending && !lastData?.manual_approval) {
if (lastData?.itemid && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') { if (lastData?.itemid && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
@@ -3416,7 +3428,11 @@ window.initUploadForm = (selector) => {
window.location.href = targetUrl; 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 { } else {
restoreBtn(); restoreBtn();
+6 -8
View File
@@ -4,17 +4,15 @@ import lib from './lib.mjs';
import cfg from './config.mjs'; import cfg from './config.mjs';
import security from './security.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 * @param {object} req
* @returns {string} * @returns {string|null}
*/ */
export function resolveAuditIP(req) { export function resolveAuditIP(req) {
if (!req) return 'unknown'; if (!req) return null;
const rawIp = security.getRealIP(req); return security.storableIP(security.getRealIP(req));
return getHashUserIps() ? security.hashIP(rawIp) : rawIp;
} }
/** /**
@@ -25,7 +23,7 @@ export function resolveAuditIP(req) {
export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) { export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) {
try { try {
const rawIp = security.getRealIP(req); const rawIp = security.getRealIP(req);
const ip = getHashUserIps() ? security.hashIP(rawIp) : rawIp; const ip = security.storableIP(rawIp);
const userId = req?.session?.id || null; const userId = req?.session?.id || null;
if (!userId) return; if (!userId) return;
const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null; 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 sessionHash = lib.sha256(session);
const csrfToken = crypto.randomBytes(24).toString('hex'); const csrfToken = crypto.randomBytes(24).toString('hex');
const stamp = ~~(Date.now() / 1e3); 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 ua = req?.headers ? (req.headers['user-agent'] || '') : '';
const sessRecord = { const sessRecord = {
+30
View File
@@ -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; };
+97
View File
@@ -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);
};
+1 -1
View File
@@ -113,7 +113,7 @@ export default (router, tpl) => {
last_used: stamp, last_used: stamp,
last_action: "/login", last_action: "/login",
kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0, kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0,
ip: ip ip: security.storableIP(ip)
}; };
await db` await db`
+2 -1
View File
@@ -1,6 +1,7 @@
import db from '../../sql.mjs'; import db from '../../sql.mjs';
import lib from '../../lib.mjs'; import lib from '../../lib.mjs';
import cfg from '../../config.mjs'; import cfg from '../../config.mjs';
import security from '../../security.mjs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
@@ -1444,7 +1445,7 @@ export default router => {
// Create a full user session (same as normal login) // Create a full user session (same as normal login)
const stamp = Math.floor(Date.now() / 1000); 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 sessionToken = crypto.randomBytes(32).toString('hex');
const csrfToken = crypto.randomBytes(32).toString('hex'); const csrfToken = crypto.randomBytes(32).toString('hex');
const sessRecord = { const sessRecord = {
+44 -1
View File
@@ -4,7 +4,8 @@ import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs";
import { createI18n } from "../i18n.mjs"; import { createI18n } from "../i18n.mjs";
import { render502 } from "../private_items.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) => { const auth = async (req, res, next) => {
if (!req.session) 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) => { router.get(/^\/(rules)$/, (req, res) => {
res.reply({ res.reply({
body: tpl.render('rules', { body: tpl.render('rules', {
+3
View File
@@ -1,6 +1,7 @@
import db from "../sql.mjs"; import db from "../sql.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import audit from "../audit.mjs"; import audit from "../audit.mjs";
import { invalidateHiddenItems } from "../hidden_items.mjs";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import fetch from "flumm-fetch"; 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. // 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. // 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`; const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
invalidateHiddenItems();
if (result.count === 1) { if (result.count === 1) {
// Mark pending upload notifications as read for staff // Mark pending upload notifications as read for staff
@@ -886,6 +888,7 @@ export default (router, tpl) => {
const item = rows[0]; const item = rows[0];
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`; 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' }); if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' });
await moveItemFilesToPublic(item, id); await moveItemFilesToPublic(item, id);
+192
View File
@@ -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\/(?<id>\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\/(?<id>\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}`
});
});
};
+1 -1
View File
@@ -65,7 +65,7 @@ export default (router, tpl) => {
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories) INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories)
VALUES ( VALUES (
${req.session ? req.session.id : null}, ${req.session ? req.session.id : null},
${ip}, ${security.storableIP(ip)},
${item_id ? +item_id : null}, ${item_id ? +item_id : null},
${comment_id ? +comment_id : null}, ${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null}, ${reported_user_id ? +reported_user_id : null},
+15
View File
@@ -3,6 +3,7 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import db from "../sql.mjs"; import db from "../sql.mjs";
import queue from "../queue.mjs"; import queue from "../queue.mjs";
import { isHiddenItem } from "../hidden_items.mjs";
export default (router, tpl) => { export default (router, tpl) => {
router.static({ router.static({
@@ -48,8 +49,21 @@ export default (router, tpl) => {
return 'application/octet-stream'; return 'application/octet-stream';
}; };
// <id>.webp / <id>_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\/(?<file>.+)$/, async (req, res) => { router.get(/^\/t\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file; const file = req.params.file;
if (await isHiddenMedia(file)) return notFound(res);
const filePath = path.join(cfg.paths.t, file); const filePath = path.join(cfg.paths.t, file);
try { try {
const stat = await fs.stat(filePath); const stat = await fs.stat(filePath);
@@ -115,6 +129,7 @@ export default (router, tpl) => {
router.get(/^\/ca\/(?<file>.+)$/, async (req, res) => { router.get(/^\/ca\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file; const file = req.params.file;
if (await isHiddenMedia(file)) return notFound(res);
const filePath = path.join(cfg.paths.ca, file); const filePath = path.join(cfg.paths.ca, file);
try { try {
const stat = await fs.stat(filePath); const stat = await fs.stat(filePath);
+12 -4
View File
@@ -178,11 +178,19 @@ export default new class {
* @param {number} userId * @param {number} userId
* @param {string} ip * @param {string} ip
*/ */
async logUserIP(userId, ip) { /**
if (!cfg.websrv.log_user_ips || !userId || !ip) return; * 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;
}
const { getHashUserIps } = await import("./settings.mjs"); async logUserIP(userId, ip) {
const finalIp = getHashUserIps() ? this.hashIP(ip) : ip; const finalIp = this.storableIP(ip);
if (!userId || !finalIp) return;
await db` await db`
insert into user_ips (user_id, ip) insert into user_ips (user_id, ip)
+8
View File
@@ -28,6 +28,7 @@ import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs"; import security from "./inc/security.mjs";
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs"; import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { startRetention } from "./inc/retention.mjs";
import { createRequire } from 'module'; import { createRequire } from 'module';
const _require = createRequire(import.meta.url); 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 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 title text DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS uploader_ip character varying(128) 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 is_album boolean DEFAULT false`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0`); await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0`);
await runMigration(db` await runMigration(db`
@@ -1930,6 +1933,8 @@ process.on('uncaughtException', err => {
get manual_approval() { return getManualApproval(); }, get manual_approval() { return getManualApproval(); },
get min_tags() { return getMinTags(); }, get min_tags() { return getMinTags(); },
get registration_open() { return getRegistrationOpen(); }, 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_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
registration_require_mail_andor_token: !!cfg.websrv.open_registration_require_mail_andor_token, registration_require_mail_andor_token: !!cfg.websrv.open_registration_require_mail_andor_token,
get trusted_uploads() { return getTrustedUploads(); }, get trusted_uploads() { return getTrustedUploads(); },
@@ -2307,4 +2312,7 @@ process.on('uncaughtException', err => {
setInterval(banInactiveUsers, INACTIVITY_BAN_INTERVAL_MS); setInterval(banInactiveUsers, INACTIVITY_BAN_INTERVAL_MS);
} }
// ── Data retention — delete / scrub personal data after websrv.retention_*_days (shown on /privacy)
startRetention();
})(); })();
+13 -4
View File
@@ -284,11 +284,13 @@ export const handleUpload = async (req, res, self) => {
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null; 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); 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); 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, visibility: targetVisibility,
manual_approval: manualApproval, manual_approval: manualApproval,
redirect: !manualApproval ? itemRoute : null, 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, file_url: null,
dest: filename, dest: filename,
mime: 'video/youtube', mime: 'video/youtube',
@@ -1422,7 +1427,11 @@ export const handleUpload = async (req, res, self) => {
visibility: targetVisibility, visibility: targetVisibility,
manual_approval: manualApproval, manual_approval: manualApproval,
redirect: !manualApproval ? itemRoute : null, 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, file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
// Fields for immediate client-side grid injection (avoids SSE race condition) // Fields for immediate client-side grid injection (avoids SSE race condition)
dest: filename, dest: filename,
+168
View File
@@ -0,0 +1,168 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="container adm up">
<header class="adm-header">
<div>
<h1 class="adm-title">My uploads</h1>
<div class="adm-subtitle">Status of your last {{ items.length }} upload@if(items.length !== 1)s@endif.</div>
</div>
<a href="/upload" class="adm-btn adm-btn-ghost"><i class="fa-solid fa-angle-up"></i> Upload</a>
</header>
@if(manual_approval_on)
<div class="up-notice"><i class="fa-solid fa-hourglass-half"></i> Manual approval is on: new uploads appear on the site once a moderator approves them.</div>
@endif
<section class="adm-section">
<div class="adm-stats">
<div class="adm-stat @if(counts.pending > 0) is-hot @endif"><span class="adm-stat-num" id="up-pending-count">{{ counts.pending }}</span><span class="adm-stat-label">Pending</span></div>
<div class="adm-stat"><span class="adm-stat-num">{{ counts.live }}</span><span class="adm-stat-label">Live</span></div>
<div class="adm-stat"><span class="adm-stat-num">{{ counts.denied + counts.removed }}</span><span class="adm-stat-label">Denied / removed</span></div>
</div>
</section>
<section class="adm-section">
<h2 class="adm-section-title"><i class="fa-solid fa-list"></i> Recent uploads</h2>
@if(items.length > 0)
<div class="up-list">
@each(items as it)
<div class="up-row is-{!! it.status !!}" id="i{!! it.id !!}">
<div class="up-thumb">
@if(it.thumb)
<img src="{!! it.thumb !!}" alt="" loading="lazy">
@else
<i class="fa-solid fa-ban"></i>
@endif
</div>
<div class="up-info">
<div class="up-name">
@if(it.status === 'live')<a href="/{!! it.path !!}">{!! it.title || ('#' + it.id) !!}</a>@else{!! it.title || ('#' + it.id) !!}@endif
@if(it.is_album)<span class="up-album"><i class="fa-solid fa-images"></i> {!! it.album_count !!}</span>@endif
</div>
<div class="up-meta">
<span>#{!! it.id !!}</span>
<span>{!! it.mime !!}</span>
@if(it.size_fmt)<span>{!! it.size_fmt !!}</span>@endif
@if(it.time_ago)<span tooltip="{!! it.time_full !!}">{!! it.time_ago !!}</span>@endif
</div>
@if(it.reason)
<div class="up-reason"><strong>Reason:</strong> {!! it.reason !!}</div>
@endif
</div>
<div class="up-status">
@if(it.status === 'pending')<span class="up-badge"><i class="fa-solid fa-hourglass-half"></i> Pending</span><button type="button" class="up-del" data-id="{!! it.id !!}" title="Delete this upload"><i class="fa-solid fa-trash"></i> <span>Delete</span></button>@endif
@if(it.status === 'live')<a href="/{!! it.path !!}" class="up-badge"><i class="fa-solid fa-check"></i> Live</a>@endif
@if(it.status === 'denied')<span class="up-badge"><i class="fa-solid fa-xmark"></i> Denied</span>@endif
@if(it.status === 'removed')<span class="up-badge"><i class="fa-solid fa-trash"></i> Removed</span>@endif
</div>
</div>
@endeach
</div>
@else
<div class="up-empty">You haven't uploaded anything yet.</div>
@endif
</section>
</div>
@include(snippets/adm-dashboard-style)
<style>
.up-notice {
display: flex; align-items: center; gap: 10px; margin: -8px 0 22px; padding: 10px 14px;
font-size: 0.85em; color: var(--adm-text); background: var(--adm-surface);
border: 1px solid var(--adm-border); border-left: 3px solid var(--adm-accent);
}
.up-notice i { color: var(--adm-accent); }
.up-list { display: flex; flex-direction: column; border: 1px solid var(--adm-border); }
.up-row { display: flex; align-items: center; gap: 14px; padding: 10px 12px; background: var(--adm-surface); scroll-margin-top: 90px; transition: background 0.3s; }
.up-row + .up-row { border-top: 1px solid var(--adm-border); }
.up-row:target, .up-row.is-target { background: color-mix(in srgb, var(--adm-accent) 12%, transparent); box-shadow: inset 3px 0 0 var(--adm-accent); }
.up-thumb { width: 64px; height: 64px; flex-shrink: 0; background: #000; display: flex; align-items: center; justify-content: center; overflow: hidden; color: var(--adm-muted); }
.up-thumb img { width: 100%; height: 100%; object-fit: cover; }
.up-row.is-denied .up-thumb, .up-row.is-removed .up-thumb { color: #ff5c5c; }
.up-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.up-name { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 0.92em; overflow: hidden; }
.up-name a { color: var(--adm-text); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.up-name a:hover { color: var(--adm-accent); }
.up-album { flex-shrink: 0; font-size: 0.78em; font-weight: 700; color: var(--adm-muted); }
.up-meta { display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 0.76em; color: var(--adm-muted); }
.up-reason { font-size: 0.8em; color: #ffb8b8; }
.up-status { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
.up-del {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; margin: 0; font: inherit; font-size: 0.72em; font-weight: 800;
text-transform: uppercase; letter-spacing: 0.08em; cursor: pointer; border-radius: 0;
background: transparent; color: var(--adm-muted); border: 1px solid var(--adm-border); transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.up-del:hover { color: #ff5c5c; border-color: rgba(255, 92, 92, 0.5); }
.up-del.is-armed { color: #000; background: #ff5c5c; border-color: #ff5c5c; }
.up-del:disabled { opacity: 0.6; cursor: progress; }
.up-row.is-gone { opacity: 0; transition: opacity 0.25s; }
.up-badge {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 0.72em; font-weight: 800;
text-transform: uppercase; letter-spacing: 0.08em; text-decoration: none; border: 1px solid var(--adm-border); color: var(--adm-muted);
}
.up-row.is-pending .up-badge { color: #ffb020; border-color: rgba(255, 176, 32, 0.5); background: rgba(255, 176, 32, 0.1); }
.up-row.is-live .up-badge { color: #000; background: var(--adm-accent); border-color: var(--adm-accent); }
.up-row.is-live .up-badge:hover { background: transparent; color: var(--adm-accent); }
.up-row.is-denied .up-badge, .up-row.is-removed .up-badge { color: #ff5c5c; border-color: rgba(255, 92, 92, 0.5); background: rgba(255, 92, 92, 0.08); }
.up-empty { padding: 40px 20px; text-align: center; color: var(--adm-muted); border: 1px dashed var(--adm-border); }
@media (max-width: 600px) {
.up-row { flex-wrap: wrap; }
.up-status { width: 100%; padding-left: 78px; }
}
</style>
<script>
(() => {
// Highlight + scroll to #i<id> (AJAX navigation uses pushState, which doesn't trigger :target)
const focusRow = () => {
document.querySelectorAll('.up-row.is-target').forEach(r => r.classList.remove('is-target'));
const id = (location.hash || '').slice(1);
const row = id && document.getElementById(id);
if (!row || !row.classList.contains('up-row')) return;
row.classList.add('is-target');
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
};
setTimeout(focusRow, 50);
window.addEventListener('hashchange', focusRow);
// Withdraw a pending upload: first click arms the button, second click deletes
const csrf = () => (window.f0ckSession && window.f0ckSession.csrf_token) || document.querySelector('meta[name="csrf-token"]')?.content || '';
const disarm = (btn) => { btn.classList.remove('is-armed'); btn.querySelector('span').textContent = 'Delete'; };
document.querySelectorAll('.up-del').forEach(btn => {
let timer = null;
btn.addEventListener('click', async () => {
if (!btn.classList.contains('is-armed')) {
btn.classList.add('is-armed');
btn.querySelector('span').textContent = 'Confirm';
timer = setTimeout(() => disarm(btn), 4000);
return;
}
clearTimeout(timer);
btn.disabled = true;
try {
const r = await fetch('/pending/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ id: btn.dataset.id })
});
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.success) throw new Error(data.msg || 'Delete failed');
const row = btn.closest('.up-row');
row.classList.add('is-gone');
setTimeout(() => row.remove(), 250);
const cnt = document.getElementById('up-pending-count');
if (cnt) cnt.textContent = Math.max(0, (+cnt.textContent || 0) - 1);
if (typeof window.flashMessage === 'function') window.flashMessage('Upload deleted', 2000, 'success');
} catch (e) {
btn.disabled = false;
disarm(btn);
if (typeof window.flashMessage === 'function') window.flashMessage(e.message, 3000, 'error'); else alert(e.message);
}
});
});
})();
</script>
</div>
</div>
@include(snippets/footer)
+188
View File
@@ -0,0 +1,188 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="pv">
<header class="pv-head">
<h1>Privacy</h1>
<p>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.</p>
</header>
{{-- ── Live configuration ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-sliders"></i> Current settings</h2>
<div class="pv-status">
<div class="pv-stat">
<span class="pv-stat-label">IP logging</span>
@if(pv.log_ips)<span class="pv-pill is-on">On</span>@else<span class="pv-pill is-off">Off</span>@endif
</div>
<div class="pv-stat">
<span class="pv-stat-label">IP storage</span>
@if(pv.ip_mode === 'hashed')<span class="pv-pill is-good">Hashed (HMAC-SHA256)</span>@elseif(pv.ip_mode === 'raw')<span class="pv-pill is-warn">Plain text</span>@else<span class="pv-pill is-good">Not stored</span>@endif
</div>
<div class="pv-stat">
<span class="pv-stat-label">Anonymous login</span>
@if(pv.anon)<span class="pv-pill is-on">Enabled</span>@else<span class="pv-pill is-off">Disabled</span>@endif
</div>
<div class="pv-stat">
<span class="pv-stat-label">Transport</span>
@if(pv.https)<span class="pv-pill is-good">HTTPS</span>@else<span class="pv-pill is-warn">HTTP</span>@endif
</div>
</div>
<p class="pv-small">
@if(pv.ip_mode === 'hashed')IP addresses are logged, but only ever written to the database as <code>HMAC-SHA256(ip, server_secret)</code>. 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
</p>
</section>
{{-- ── Retention ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-hourglass-half"></i> Retention</h2>
<p>A cleanup job runs hourly and deletes or blanks data older than these periods.</p>
<div class="pv-table">
<div class="pv-row pv-row-head"><span>Data</span><span>Kept for</span><span>What happens after</span></div>
<div class="pv-row"><span>Stored IP addresses</span><span class="@if(pv.ret_on.ip)pv-ok@else pv-warn@endif">{{ pv.ret.ip }}</span><span><code>user_ips</code> rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to <code>NULL</code>.</span></div>
<div class="pv-row"><span>Anonymous activity log</span><span class="@if(pv.ret_on.activity)pv-ok@else pv-warn@endif">{{ pv.ret.activity }}</span><span>Rows deleted (action, IP, identity and device fingerprint).</span></div>
<div class="pv-row"><span>Login attempts</span><span class="@if(pv.ret_on.login)pv-ok@else pv-warn@endif">{{ pv.ret.login }}</span><span>Rows deleted (hashed IP, attempted username, result).</span></div>
<div class="pv-row"><span>Unused sessions</span><span class="@if(pv.ret_on.sessions)pv-ok@else pv-warn@endif">{{ pv.ret.sessions }}</span><span>Session deleted after this long without use; that device is logged out.</span></div>
<div class="pv-row"><span>Device fingerprint</span><span class="@if(pv.ret_on.fp)pv-ok@else pv-warn@endif">{{ pv.ret.fp }}</span><span>Cleared from anonymous identities that haven't been used for this long.</span></div>
<div class="pv-row"><span>Active bans</span><span>Until expiry</span><span>Banned IP hashes and fingerprints are kept until the ban expires or is lifted.</span></div>
<div class="pv-row"><span>Your content</span><span>Until deleted</span><span>Uploads, comments, favourites and your account itself.</span></div>
</div>
</section>
{{-- ── IP addresses ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-network-wired"></i> IP addresses</h2>
<p>The client IP is taken from the first of <code>CF-Connecting-IP</code>, <code>True-Client-IP</code>, <code>X-Client-IP</code>, <code>X-Real-IP</code>, <code>X-Forwarded-For</code> (first entry) or the TCP peer address.</p>
@if(pv.log_ips)
<p>With IP logging on, the IP is recorded @if(pv.hash_ips)as an HMAC@else in plain text@endif in:</p>
<ul class="pv-list">
<li><code>user_sessions.ip</code>: updated on each request of a logged-in session</li>
<li><code>user_ips</code>: one row per account and IP with first/last seen time</li>
<li><code>anon_identities.created_ip / last_ip</code> and <code>anon_activity_log.ip</code> for anonymous identities</li>
<li><code>items.uploader_ip</code>, <code>comments.ip</code>, <code>reports.reporter_ip</code></li>
</ul>
@endif
@if(pv.hash_ips)
<p><strong>Hashing:</strong> <code>HMAC-SHA256</code> 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.</p>
@endif
<p><strong>Always, regardless of the logging setting:</strong> login and registration attempts store an HMAC of the IP in <code>login_attempts</code> for brute-force rate limiting, and a moderator ban stores the banned IP's hash in <code>banned_ips</code>.</p>
</section>
@if(pv.anon)
{{-- ── Anonymous login ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-user-secret"></i> Anonymous login (WebAuthn passkey)</h2>
<p>No email, password or name is involved. <em>Login as Anonymous</em> creates a standard WebAuthn passkey in your authenticator (browser, OS, Bitwarden, iCloud Keychain, …).</p>
<h3>Registration</h3>
<ol class="pv-steps">
<li>The server sends creation options: relying party <code>{{ pv.domain }}</code>, a random single-use challenge, algorithm <strong>ES256</strong> (ECDSA P-256, COSE <code>-7</code>), <code>attestation: "none"</code>, and a user handle of 16 random bytes named <code>anon@{{ pv.domain }}</code> / "Anonymous". Nothing about you goes into it.</li>
<li>Your authenticator generates a key pair. The <strong>private key never leaves the authenticator</strong>.</li>
<li>The server verifies the response and stores: the <strong>credential ID</strong>, the <strong>public key</strong> (SPKI), the signature counter, and the authenticator's <strong>AAGUID</strong> (identifies the authenticator model, e.g. a password manager; with attestation "none" it is often all zeros).</li>
<li>Your identity is derived from the credential ID: <code>SHA256:base64(SHA-256(credential_id))</code>; the account name is <code>anon_</code> plus the first 8 hex characters of that hash (e.g. <code>anon_1ad1e20c</code>).</li>
</ol>
<h3>Login</h3>
<p>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.</p>
</section>
{{-- ── Device fingerprint ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-microchip"></i> Device fingerprint</h2>
<p>At anonymous login and when adding a passkey, your browser computes a device fingerprint used <strong>only for ban enforcement</strong> (so a banned user can't just create a new identity). It is not used for advertising or cross-site tracking.</p>
<p>Inputs, all read locally in your browser:</p>
<ul class="pv-list">
<li>WebGL: unmasked GPU vendor and renderer, 6 capability limits (max texture/renderbuffer size, vertex attribs, uniform/varying vectors, texture units)</li>
<li>WebGPU adapter info (architecture, vendor, description), where available</li>
<li><code>navigator.hardwareConcurrency</code>, <code>deviceMemory</code>, <code>platform</code>, <code>maxTouchPoints</code></li>
<li>Screen width × height, colour depth, device pixel ratio</li>
<li>Canvas 2D: checksum of a small rendered test image (text + shapes)</li>
<li>Audio: sum of samples from an <code>OfflineAudioContext</code> rendering a test tone through a compressor</li>
</ul>
<p>The values are concatenated and hashed <strong>in the browser</strong> with SHA-256; only <code>HW:&lt;64 hex&gt;</code> is sent. The raw values never reach the server. The hash is cached in <code>localStorage</code> (<code>f0ck_anon_hw_fp</code>) and stored server-side in <code>anon_identities.hw_fingerprint</code> and the activity log, and compared against banned device hashes.</p>
</section>
{{-- ── Activity log ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-list-check"></i> Anonymous activity log</h2>
<p><code>anon_activity_log</code> 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.</p>
</section>
@endif
{{-- ── Sessions & browser storage ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-cookie"></i> Sessions, cookies and browser storage</h2>
<ul class="pv-list">
<li><strong><code>session</code> cookie</strong>: 32 random bytes; the server stores only its SHA-256, so a database leak doesn't expose usable sessions. Flags: <code>HttpOnly</code>, <code>SameSite=Lax</code>@if(pv.https), <code>Secure</code>@endif.</li>
<li>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.</li>
<li><code>localStorage</code>: UI preferences, and for anonymous users the device fingerprint hash.</li>
<li><code>f0ck_banned</code> cookie / <code>f0ck_anon_tombstone</code>: only set if you are banned, to show the ban notice.</li>
</ul>
<p>Registered accounts: passwords are hashed with <strong>scrypt</strong> (random 16-byte salt, 64-byte key). The plain password is never stored.</p>
</section>
<section class="pv-sec">
<h2><i class="fa-solid fa-ban"></i> Not collected</h2>
<p>For anonymous identities: no email, real name, phone number or password. No third-party analytics, trackers or ad networks are involved in authentication.</p>
</section>
<p class="pv-foot">Questions? See <a href="/about">About</a>@if(mail) or write to <a href="mailto:{!! mail !!}">{!! mail !!}</a>@endif.</p>
</div>
<style>
.pv {
--pv-accent: var(--accent, #0096ff);
--pv-text: var(--text-color, #fff);
--pv-muted: var(--text-muted, #8a8f98);
--pv-surface: rgba(255, 255, 255, 0.04);
--pv-border: rgba(255, 255, 255, 0.1);
--pv-good: #3ecf8e;
--pv-warn: #ffb020;
max-width: 860px; margin: 0 auto; padding: 32px 16px 60px; color: var(--pv-text); line-height: 1.6;
}
.pv * { border-radius: 0 !important; }
.pv-head { padding-bottom: 18px; margin-bottom: 26px; border-bottom: 1px solid var(--pv-accent); }
.pv-head h1 { margin: 0; font-size: 1.8em; font-weight: 800; }
.pv-head p { margin: 6px 0 0; color: var(--pv-muted); }
.pv-sec { margin-bottom: 32px; }
.pv-sec h2 { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; font-size: 1.1em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; }
.pv-sec h2 i { color: var(--pv-accent); font-size: 0.9em; }
.pv-sec h3 { margin: 18px 0 8px; font-size: 0.9em; font-weight: 700; color: var(--pv-muted); text-transform: uppercase; letter-spacing: 0.08em; }
.pv p { margin: 0 0 10px; }
.pv a { color: var(--pv-accent); }
.pv code { padding: 1px 5px; font-size: 0.86em; background: var(--pv-surface); border: 1px solid var(--pv-border); word-break: break-word; }
.pv-small { font-size: 0.88em; color: var(--pv-muted); }
.pv-steps, .pv-list { margin: 0 0 12px; padding-left: 22px; }
.pv-steps li, .pv-list li { margin-bottom: 6px; }
.pv-status { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 12px; background: var(--pv-border); border: 1px solid var(--pv-border); }
.pv-stat { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: var(--bg, #000); }
.pv-stat-label { font-size: 0.72em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--pv-muted); }
.pv-pill { align-self: flex-start; padding: 3px 9px; font-size: 0.78em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; border: 1px solid currentColor; }
.pv-pill.is-on { color: var(--pv-accent); }
.pv-pill.is-off { color: var(--pv-muted); }
.pv-pill.is-good { color: var(--pv-good); }
.pv-pill.is-warn { color: var(--pv-warn); }
.pv-table { border: 1px solid var(--pv-border); font-size: 0.88em; }
.pv-row { display: grid; grid-template-columns: 1fr 0.7fr 2fr; gap: 12px; padding: 10px 14px; background: var(--pv-surface); }
.pv-row + .pv-row { border-top: 1px solid var(--pv-border); }
.pv-row-head { background: transparent; font-size: 0.8em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--pv-muted); }
.pv-row span:first-child { font-weight: 700; }
.pv-row span:last-child { color: var(--pv-muted); }
.pv-row-head span { font-weight: 800 !important; }
.pv-ok { color: var(--pv-good); font-weight: 700; }
.pv-warn { color: var(--pv-warn); font-weight: 700; }
.pv-foot { margin-top: 36px; padding-top: 16px; border-top: 1px solid var(--pv-border); color: var(--pv-muted); font-size: 0.9em; }
@media (max-width: 700px) {
.pv-status { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.pv-row { grid-template-columns: 1fr; gap: 2px; }
.pv-row-head { display: none; }
}
</style>
</div>
</div>
@include(snippets/footer)
+82 -8
View File
@@ -1,16 +1,89 @@
@if(session) @if(session)
<div id="mod-action-modal" class="modal-overlay" style="display:none;"> <div id="mod-action-modal" class="modal-overlay" style="display:none;">
<div class="modal-content"> <div class="modal-content ma-modal-content">
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3> <div class="ma-header">
<div id="mod-action-content"></div> <h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
<textarea id="mod-reason" class="mod-reason" placeholder="{{ t('mod.reason_placeholder') }}"></textarea> </div>
<div id="mod-action-error" class="error-msg"></div> <div class="ma-body">
<div class="modal-actions"> <div id="mod-action-content"></div>
<button id="mod-action-confirm" class="btn-danger">{{ t('mod.confirm') }}</button> <textarea id="mod-reason" class="mod-reason" placeholder="{{ t('mod.reason_placeholder') }}"></textarea>
<button id="mod-action-cancel" class="btn-secondary">{{ t('common.cancel') }}</button> <div id="mod-action-error" class="error-msg"></div>
</div>
<div class="modal-actions ma-footer">
<button type="button" id="mod-action-cancel" class="btn-secondary">{{ t('common.cancel') }}</button>
<button type="button" id="mod-action-confirm" class="btn-danger">{{ t('mod.confirm') }}</button>
</div> </div>
</div> </div>
</div> </div>
<style>
/* Moderator action modal — same rules as the report/info/visibility modals */
#mod-action-modal .ma-modal-content {
--ma-accent: var(--accent, #0096ff);
--ma-text: var(--text-color, #fff);
--ma-muted: var(--text-muted, #8a8f98);
--ma-surface: rgba(255, 255, 255, 0.04);
--ma-border: rgba(255, 255, 255, 0.1);
--ma-danger: #ff5c5c;
width: min(440px, 92vw); min-width: 0;
max-height: min(90vh, 90dvh);
display: flex; flex-direction: column; overflow: hidden;
padding: 0; text-align: left;
border-radius: 0 !important;
border: 1px solid var(--accent) !important;
background: var(--bg) !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
color: var(--ma-text);
}
#mod-action-modal .ma-modal-content * { border-radius: 0 !important; }
#mod-action-modal .ma-header { flex-shrink: 0; padding: 18px 24px 14px; border-bottom: 1px solid var(--ma-border); }
#mod-action-modal .ma-header h3 { margin: 0; font-size: 1.2em; font-weight: 700; color: var(--ma-text); overflow-wrap: anywhere; }
#mod-action-modal .ma-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
padding: 16px 24px; display: flex; flex-direction: column; gap: 12px;
}
#mod-action-modal #mod-action-content { font-size: 0.9em; line-height: 1.5; color: var(--ma-muted); overflow-wrap: anywhere; }
#mod-action-modal #mod-action-content:empty { display: none; }
#mod-action-modal #mod-action-content strong, #mod-action-modal #mod-action-content b { color: var(--ma-text); }
#mod-action-modal textarea.mod-reason {
width: 100%; min-height: 0; box-sizing: border-box; margin: 0; padding: 10px 12px;
background: var(--ma-surface) !important; border: 1px solid var(--ma-border) !important; color: var(--ma-text) !important;
font: inherit; font-size: 0.9em; outline: none; box-shadow: none !important; transition: border-color 0.15s;
}
#mod-action-modal textarea.mod-reason:focus { border-color: var(--ma-accent) !important; }
#mod-action-modal textarea.mod-reason::placeholder { color: var(--ma-muted); }
#mod-action-modal #mod-action-error {
margin: 0; padding: 10px 12px; font-size: 0.84em; line-height: 1.45; color: #ff8a8a; text-align: left;
background: rgba(224, 108, 117, 0.1); border: 1px solid rgba(224, 108, 117, 0.3);
}
#mod-action-modal #mod-action-error:empty { display: none !important; }
#mod-action-modal .ma-footer {
flex-shrink: 0; display: flex; justify-content: flex-end; gap: 8px; margin: 0;
padding: 14px 24px; border-top: 1px solid var(--ma-border); background: rgba(0, 0, 0, 0.2);
}
#mod-action-modal .ma-footer button {
height: 40px; margin: 0; padding: 0 18px; font: inherit; font-size: 0.85em; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; transition: background 0.18s, color 0.18s, border-color 0.18s;
}
#mod-action-modal #mod-action-cancel { background: transparent; border: 1px solid var(--ma-border); color: var(--ma-muted); font-weight: 600; }
#mod-action-modal #mod-action-cancel:hover { color: var(--ma-text); border-color: rgba(255, 255, 255, 0.25); background: var(--ma-surface); }
#mod-action-modal #mod-action-confirm {
background: var(--ma-danger); border: 1px solid var(--ma-danger); color: #000;
font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
}
#mod-action-modal #mod-action-confirm:hover:not(:disabled) { background: transparent; color: var(--ma-danger); }
#mod-action-modal #mod-action-confirm:disabled { opacity: 0.6; cursor: progress; }
#mod-action-modal .ma-footer button:focus-visible { outline: 2px solid var(--ma-accent); outline-offset: 2px; }
@media (max-width: 600px) {
#mod-action-modal { padding: 0 !important; align-items: stretch !important; }
#mod-action-modal .ma-modal-content {
width: 100vw; max-width: none; height: 100vh; height: 100dvh; max-height: none;
border: 0 !important; box-shadow: none !important;
}
#mod-action-modal .ma-footer { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
#mod-action-modal .ma-footer button { flex: 1; }
}
</style>
@endif @endif
@if(session) @if(session)
@@ -345,6 +418,7 @@
<a href="/ranking">{{ t('footer.ranking') }}</a> <a href="/ranking">{{ t('footer.ranking') }}</a>
<a href="/rules">{{ t('footer.rules') }}</a> <a href="/rules">{{ t('footer.rules') }}</a>
<a href="/about">{{ t('footer.about') }}</a> <a href="/about">{{ t('footer.about') }}</a>
<a href="/privacy">Privacy</a>
<div id="help-button" style="color: var(--accent); font-weight: bold; opacity: 0.7;" title="Keyboard Shortcuts">?</div> <div id="help-button" style="color: var(--accent); font-weight: bold; opacity: 0.7;" title="Keyboard Shortcuts">?</div>
</div> </div>
</div> </div>
+8 -1
View File
@@ -68,6 +68,7 @@
<a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a> <a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a>
@endif @endif
@endif @endif
<a href="/pending">Pending Uploads</a>
@if(enable_anonymous_access && session.is_anon) @if(enable_anonymous_access && session.is_anon)
<a href="#" id="nav-user-anon-identity-btn" style="white-space: nowrap;" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }">Passkey Identity</a> <a href="#" id="nav-user-anon-identity-btn" style="white-space: nowrap;" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }">Passkey Identity</a>
@endif @endif
@@ -460,7 +461,10 @@ async function loginWithPasskey() {
<div class="lm-divider"><span>or</span></div> <div class="lm-divider"><span>or</span></div>
<button type="button" id="modal-login-as-anon-btn" class="lm-btn lm-btn-ghost" <button type="button" id="modal-login-as-anon-btn" class="lm-btn lm-btn-ghost"
onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }"> onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }">
<i class="fa-solid fa-user-secret"></i> Login as anonymous <i class="fa-solid fa-user-secret"></i> Login as Anonymous
</button>
<button type="button" id="modal-login-register-btn" class="lm-btn lm-btn-ghost" style="margin-top: 8px;">
<i class="fa-solid fa-user-plus"></i> Register
</button> </button>
</div> </div>
@endif @endif
@@ -821,6 +825,9 @@ async function loginWithPasskey() {
<p class="lm-note"> <p class="lm-note">
<i class="fa-solid fa-rotate"></i> You can use it across devices if your passkey manager syncs (e.g. Bitwarden). <i class="fa-solid fa-rotate"></i> You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
</p> </p>
<p class="lm-note">
<i class="fa-solid fa-shield-halved"></i> 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. <a href="/privacy" target="_blank" class="lm-link">What exactly is stored?</a>
</p>
<button type="button" id="anon-setup-create-btn" class="lm-btn lm-btn-primary"> <button type="button" id="anon-setup-create-btn" class="lm-btn lm-btn-primary">
<i class="fa-solid fa-fingerprint"></i> Create my passkey <i class="fa-solid fa-fingerprint"></i> Create my passkey
</button> </button>
+3 -3
View File
@@ -2,7 +2,7 @@
@if(n.type === 'approve') @if(n.type === 'approve')
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="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';};};"/> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="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';};};"/>
</div> </div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div> <div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
@@ -24,7 +24,7 @@
@elseif(n.type === 'report') @elseif(n.type === 'report')
<a href="/mod/reports" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/mod/reports" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="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';};};"/> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="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';};};"/>
</div> </div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div> <div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
@@ -86,7 +86,7 @@
<a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
@if(n.item_id) @if(n.item_id)
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div> </div>
@endif @endif
<div class="notif-content"> <div class="notif-content">