fdsafsad
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-18
@@ -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 {
|
||||
|
||||
+27
-25
@@ -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 {
|
||||
</div>
|
||||
`;
|
||||
} 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 ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="${thumbSrc}" alt="thumb" onerror="${thumbOnerror}"></div>` : '';
|
||||
return `
|
||||
@@ -19089,7 +19091,7 @@ class NotificationSystem {
|
||||
const link = `/${itemKey}`;
|
||||
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 || ''}">
|
||||
<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>
|
||||
<strong>${(window.f0ckI18n && window.f0ckI18n.notif_upload_approved) || 'Your Upload has been approved'}</strong>
|
||||
@@ -19157,7 +19159,7 @@ class NotificationSystem {
|
||||
const link = '/mod/approve';
|
||||
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 || ''}">
|
||||
<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>
|
||||
<strong>${(window.f0ckI18n && window.f0ckI18n.notif_upload_pending) || 'A new upload needs approval'}</strong>
|
||||
@@ -19172,7 +19174,7 @@ class NotificationSystem {
|
||||
const link = '/mod/reports';
|
||||
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 || ''}">
|
||||
<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>
|
||||
<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_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.';
|
||||
|
||||
@@ -3832,8 +3832,9 @@
|
||||
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>`;
|
||||
} else {
|
||||
const thumbSrc = n.type === 'admin_pending' ? `/mod/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.style.display='none'"></div>` : '';
|
||||
// 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 ? `<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}">
|
||||
${thumb}
|
||||
|
||||
+17
-1
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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; };
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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`
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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},
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
// <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) => {
|
||||
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\/(?<file>.+)$/, 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);
|
||||
|
||||
+12
-4
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
})();
|
||||
|
||||
+13
-4
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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:<64 hex></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)
|
||||
@@ -1,16 +1,89 @@
|
||||
@if(session)
|
||||
<div id="mod-action-modal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
|
||||
<div id="mod-action-content"></div>
|
||||
<textarea id="mod-reason" class="mod-reason" placeholder="{{ t('mod.reason_placeholder') }}"></textarea>
|
||||
<div id="mod-action-error" class="error-msg"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="mod-action-confirm" class="btn-danger">{{ t('mod.confirm') }}</button>
|
||||
<button id="mod-action-cancel" class="btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<div class="modal-content ma-modal-content">
|
||||
<div class="ma-header">
|
||||
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
|
||||
</div>
|
||||
<div class="ma-body">
|
||||
<div id="mod-action-content"></div>
|
||||
<textarea id="mod-reason" class="mod-reason" placeholder="{{ t('mod.reason_placeholder') }}"></textarea>
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
@if(session)
|
||||
@@ -345,6 +418,7 @@
|
||||
<a href="/ranking">{{ t('footer.ranking') }}</a>
|
||||
<a href="/rules">{{ t('footer.rules') }}</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>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
<a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a>
|
||||
@endif
|
||||
@endif
|
||||
<a href="/pending">Pending Uploads</a>
|
||||
@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>
|
||||
@endif
|
||||
@@ -460,7 +461,10 @@ async function loginWithPasskey() {
|
||||
<div class="lm-divider"><span>or</span></div>
|
||||
<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'; }">
|
||||
<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>
|
||||
</div>
|
||||
@endif
|
||||
@@ -821,6 +825,9 @@ async function loginWithPasskey() {
|
||||
<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).
|
||||
</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">
|
||||
<i class="fa-solid fa-fingerprint"></i> Create my passkey
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||
@@ -24,7 +24,7 @@
|
||||
@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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<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 || '' }}">
|
||||
@if(n.item_id)
|
||||
<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>
|
||||
@endif
|
||||
<div class="notif-content">
|
||||
|
||||
Reference in New Issue
Block a user