diff --git a/config_example.json b/config_example.json index b701699..4dcf378 100644 --- a/config_example.json +++ b/config_example.json @@ -103,6 +103,11 @@ "background": true, "log_user_ips": false, "hash_user_ips": true, + "retention_ip_days": 30, + "retention_activity_log_days": 90, + "retention_login_attempts_days": 30, + "retention_sessions_days": 365, + "retention_fingerprint_days": 365, "description": "Example Description", "themes": [ "amoled" diff --git a/config_example.yaml b/config_example.yaml index 98e9771..3180d99 100644 --- a/config_example.yaml +++ b/config_example.yaml @@ -93,6 +93,12 @@ websrv: background: true log_user_ips: false hash_user_ips: true + # Data retention in days (0 = keep forever). Shown to users on /privacy. + retention_ip_days: 30 # stored IPs: user_ips rows deleted, IP columns elsewhere set to NULL + retention_activity_log_days: 90 # anonymous activity log (actions, IP, fingerprints) + retention_login_attempts_days: 30 # failed/successful login attempts (hashed IP + username) + retention_sessions_days: 365 # sessions unused this long are deleted + retention_fingerprint_days: 365 # device fingerprint of anonymous identities inactive this long description: Example Description themes: - amoled diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index de86180..a7e7f47 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -260,7 +260,7 @@ html[theme='amoled'] { --nav-link-background-linear-gradient: rgba(255, 255, 255, .04), rgba(255, 255, 255, 0); --nav-link-box-shadow: inset 0 0 0 1px rgb(92, 92, 92), inset 0 1px rgb(92, 92, 92), inset 0 -1px rgb(92, 92, 92), 0 1px 1px rgba(92, 92, 92, 0); --nav-link-hover-bg: #6a6a6a70; - --nav-border-color: rgba(255, 255, 255, .05); + --nav-border-color: rgba(255, 255, 255, .20); --dropdown-bg: #232323; --dropdown-item-hover: #0d0d0d; --nav-brand-font: 'VCR'; @@ -2660,11 +2660,12 @@ body.layout-modern .global-sidebar-right { } -/* Edge zone drag handle — simple centered vertical pill */ +/* Edge zone drag handle — pill tucked into the zone's right edge, slides out a little on hover */ #sidebar-drag-zone { display: flex; align-items: center; - justify-content: center; + justify-content: flex-end; + overflow: hidden; top: var(--navbar-h, 50px) !important; opacity: 0; transition: opacity 0.2s ease; @@ -2673,11 +2674,14 @@ body.layout-modern .global-sidebar-right { #sidebar-drag-zone::after { content: ''; display: block; - width: 4px; + flex-shrink: 0; + width: 5px; height: 40px; border-radius: 2px; background: var(--accent, #888); - transition: height 0.2s ease, opacity 0.2s ease; + /* Resting: only a sliver peeks past the edge */ + transform: translateX(3px); + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), height 0.28s cubic-bezier(0.22, 1, 0.36, 1); } #sidebar-drag-zone:hover { @@ -2688,17 +2692,20 @@ body.sidebar-right-hidden #sidebar-drag-zone { opacity: 0.7; } -/* Always show the handle on touch devices (no hover state available) */ +#sidebar-drag-zone:hover::after { + transform: translateX(-3px); + height: 56px; +} + +/* Touch devices: no hover, so the handle stays fully out */ @media (pointer: coarse) { #sidebar-drag-zone { opacity: 0.7; - justify-content: flex-end; - padding-right: 6px; + padding-right: 3px; + } + #sidebar-drag-zone::after { + transform: none; } -} - -#sidebar-drag-zone:hover::after { - height: 56px; } /* Left sidebar edge zone drag handle — mirrors #sidebar-drag-zone */ @@ -11171,7 +11178,7 @@ input#s_avatar { font-size: 0.8em; font-weight: bold; cursor: pointer; - border-radius: 3px; + border-radius: 0; transition: all 0.2s; display: flex; align-items: center; @@ -11344,7 +11351,7 @@ input#s_avatar { background: none; border: 1px solid transparent; min-width: unset; - border-radius: 3px; + border-radius: 0; line-height: 1; opacity: 1; display: flex; @@ -11353,13 +11360,19 @@ input#s_avatar { transition: border-color 0.2s, background 0.2s; } +/* Hover / open state for the dropdown buttons (square, no glow) */ +.nav-user-dropdown:hover > .nav-user-btn, .nav-avatar-btn.is-active { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.15); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-top-right-radius: 0; - border-top-left-radius: 0; + opacity: 1; +} + +.nav-user-dropdown, +.nav-user-dropdown .nav-user-btn, +.nav-user-dropdown .nav-avatar-img, +.nav-user-dropdown .nav-user-menu { + border-radius: 0 !important; } .nav-avatar-btn.is-active .nav-avatar-caret { diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index a2537d9..98f8316 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -2530,7 +2530,7 @@ window.cancelAnimFrame = (function () { document.title = returnTitle; } const returnPath = new URL(returnUrl, window.location.origin).pathname; - const isStaticReturn = returnPath.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/); + const isStaticReturn = returnPath.match(/^\/(about|rules|terms|privacy|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/); if (isStaticReturn && typeof window.loadPageAjax === 'function') { window.loadPageAjax(returnUrl, true, { skipPush: true }); } @@ -2826,7 +2826,7 @@ window.cancelAnimFrame = (function () { e.preventDefault(); const email = document.getElementById('forgot-email').value; const status = document.getElementById('forgot-status'); - const btn = forgotForm.querySelector('button'); + const btn = forgotForm.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = i18n.sending || 'Sending...'; @@ -2868,7 +2868,7 @@ window.cancelAnimFrame = (function () { const password = document.getElementById('reset-password').value; const password_confirm = document.getElementById('reset-password-confirm').value; const status = document.getElementById('reset-status'); - const btn = resetForm.querySelector('button'); + const btn = resetForm.querySelector('button[type="submit"]'); if (password !== password_confirm) { status.className = 'flash-error'; @@ -2917,7 +2917,7 @@ window.cancelAnimFrame = (function () { e.preventDefault(); switchModalView('login'); resetToLogin.style.display = 'none'; - resetForm.querySelector('button').style.display = 'inline-block'; + resetForm.querySelector('button[type="submit"]').style.display = 'inline-block'; }); } } @@ -2945,7 +2945,8 @@ window.cancelAnimFrame = (function () { const formData = new FormData(registerForm); const params = new URLSearchParams(formData); const status = document.getElementById('register-status'); - const btn = registerForm.querySelector('button'); + const btn = registerForm.querySelector('button[type="submit"]'); + const btnLabel = btn.textContent; const password = formData.get('password'); const password_confirm = formData.get('password_confirm'); @@ -3014,7 +3015,7 @@ window.cancelAnimFrame = (function () { } } finally { btn.disabled = false; - btn.textContent = 'Create Account'; + btn.textContent = btnLabel; } }); } @@ -3022,14 +3023,16 @@ window.cancelAnimFrame = (function () { // Switch to register from login // Switch to register from login - const loginToRegister = document.getElementById('login-to-register'); - if (loginToRegister) { - loginToRegister.addEventListener('click', (e) => { + // "Register now" link and the Register button under "Login as Anonymous" + ['login-to-register', 'modal-login-register-btn'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener('click', (e) => { e.preventDefault(); closeModal(loginModal); openModal(registerModal); }); - } + }); // Switch to login from register const registerToLogin = document.getElementById('register-to-login'); @@ -11339,7 +11342,7 @@ window.cancelAnimFrame = (function () { const isAdmin = !!pathname.match(/^\/admin/); const isMod = !!pathname.match(/^\/mod/); const isSettings = pathname.match(/\/settings\/?(?:$|\?)/); - const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes)($|\/|\?)/); + const isStatic = pathname.match(/\/(about|rules|terms|privacy|upload|subscriptions|stats|docs|discord|ranking|meme|memes|pending)($|\/|\?)/); const isUpload = pathname.match(/\/upload\/?(?:$|\?)/); const isItem = typeof isItemPath === 'function' ? isItemPath(pathname) : (!pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(pathname)); const isMessages = !!pathname.match(/^\/messages(\/|$)/); @@ -13583,7 +13586,7 @@ window.cancelAnimFrame = (function () { pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+(?:[?#]|$)/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || - pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || + pathname.match(/^\/(about|rules|terms|privacy|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss') )) || (pathname.startsWith('/4/') && isItemPath(pathname)) @@ -17144,8 +17147,8 @@ window.cancelAnimFrame = (function () { window.addEventListener('resize', syncEdgeZone, { passive: true }); // Returns true if (clientX, clientY) is within the pill's hit area. - // The pill is #sidebar-drag-zone::after — 4×40px, centered on pointer:fine, - // right-aligned with padding-right:6px on pointer:coarse. + // The pill is #sidebar-drag-zone::after — 5×40px, right-aligned at the zone's edge + // (padding-right:3px on pointer:coarse, peeking out on hover for pointer:fine). const isWithinPillArea = (clientX, clientY) => { const rect = edgeZone.getBoundingClientRect(); const pillH = 40; @@ -17153,10 +17156,9 @@ window.cancelAnimFrame = (function () { // Vertical: pill is always centered in the drag zone const pillCenterY = rect.top + rect.height / 2; if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false; - // Horizontal: right-aligned on touch, centered on mouse - const isCoarse = window.matchMedia('(pointer: coarse)').matches; - const pillCenterX = isCoarse ? rect.right - 6 - 2 : rect.left + rect.width / 2; - return clientX >= pillCenterX - 2 - hitPad && clientX <= pillCenterX + 2 + hitPad; + // Horizontal: right-aligned against the zone's edge + const pillCenterX = rect.right - 3 - 2.5; + return clientX >= pillCenterX - 2.5 - hitPad && clientX <= pillCenterX + 2.5 + hitPad; }; // Touchend: tap on the pill toggles sidebar; rest of drag zone only responds to swipe @@ -19050,7 +19052,7 @@ class NotificationSystem { else msg = (window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented'; } - // For admin_pending the thumbnail lives in /mod/pending/t/ until approved + // Pending thumbnails live in /pending/t/ until approved (served to the uploader and to staff only) let thumbSrc, thumbOnerror; if (n.type === 'warning') { return ` @@ -19064,11 +19066,11 @@ class NotificationSystem { `; } else if (n.type === 'admin_pending') { - thumbSrc = `/mod/pending/t/${n.item_id}.webp`; + thumbSrc = `/pending/t/${n.item_id}.webp`; thumbOnerror = `this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}`; } else { thumbSrc = `/t/${n.item_id}.webp`; - thumbOnerror = `this.onerror=null;this.src='/mod/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}`; + thumbOnerror = `this.onerror=null;this.src='/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}`; } const thumb = n.item_id ? `






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.
+
+ @if(pv.ip_mode === 'hashed')IP addresses are logged, but only ever written to the database as HMAC-SHA256(ip, server_secret). The raw address is not persisted.@endif
+ @if(pv.ip_mode === 'raw')IP addresses are logged and written to the database in plain text.@endif
+ @if(pv.ip_mode === 'off')IP addresses are not written to the database. They are only held in memory while a request is processed (e.g. to check bans and rate limits).@endif
+
A cleanup job runs hourly and deletes or blanks data older than these periods.
+user_ips rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to NULL.The client IP is taken from the first of CF-Connecting-IP, True-Client-IP, X-Client-IP, X-Real-IP, X-Forwarded-For (first entry) or the TCP peer address.
With IP logging on, the IP is recorded @if(pv.hash_ips)as an HMAC@else in plain text@endif in:
+user_sessions.ip: updated on each request of a logged-in sessionuser_ips: one row per account and IP with first/last seen timeanon_identities.created_ip / last_ip and anon_activity_log.ip for anonymous identitiesitems.uploader_ip, comments.ip, reports.reporter_ipHashing: HMAC-SHA256 keyed with a server-side secret, stored as 64 hex characters. The same IP always yields the same hash, which is what makes bans and abuse correlation work; without the secret the hash can't be reversed or recomputed. This is pseudonymisation, not anonymisation: whoever holds the secret could test candidate IPs against it.
Always, regardless of the logging setting: login and registration attempts store an HMAC of the IP in login_attempts for brute-force rate limiting, and a moderator ban stores the banned IP's hash in banned_ips.
No email, password or name is involved. Login as Anonymous creates a standard WebAuthn passkey in your authenticator (browser, OS, Bitwarden, iCloud Keychain, …).
+{{ pv.domain }}, a random single-use challenge, algorithm ES256 (ECDSA P-256, COSE -7), attestation: "none", and a user handle of 16 random bytes named anon@{{ pv.domain }} / "Anonymous". Nothing about you goes into it.SHA256:base64(SHA-256(credential_id)); the account name is anon_ plus the first 8 hex characters of that hash (e.g. anon_1ad1e20c).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.
+At anonymous login and when adding a passkey, your browser computes a device fingerprint used only for ban enforcement (so a banned user can't just create a new identity). It is not used for advertising or cross-site tracking.
+Inputs, all read locally in your browser:
+navigator.hardwareConcurrency, deviceMemory, platform, maxTouchPointsOfflineAudioContext rendering a test tone through a compressorThe values are concatenated and hashed in the browser with SHA-256; only HW:<64 hex> is sent. The raw values never reach the server. The hash is cached in localStorage (f0ck_anon_hw_fp) and stored server-side in anon_identities.hw_fingerprint and the activity log, and compared against banned device hashes.
anon_activity_log records these actions of anonymous identities: login/session handshake, favourite, unfavourite, favourites import. Each row holds the action, target item, time, identity fingerprint, device fingerprint, and the IP @if(pv.ip_mode === 'hashed')(hashed)@endif @if(pv.ip_mode === 'off')(empty, as IP logging is off)@endif. It exists for moderation and ban cascades.
session cookie: 32 random bytes; the server stores only its SHA-256, so a database leak doesn't expose usable sessions. Flags: HttpOnly, SameSite=Lax@if(pv.https), Secure@endif.localStorage: UI preferences, and for anonymous users the device fingerprint hash.f0ck_banned cookie / f0ck_anon_tombstone: only set if you are banned, to show the ban notice.Registered accounts: passwords are hashed with scrypt (random 16-byte salt, 64-byte key). The plain password is never stored.
+For anonymous identities: no email, real name, phone number or password. No third-party analytics, trackers or ad networks are involved in authentication.
+Questions? See About@if(mail) or write to {!! mail !!}@endif.
+You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
++ To stop ban evasion we store a hashed device fingerprint@if(privacy_ip_mode === 'hashed') and a hashed IP address@elseif(privacy_ip_mode === 'raw') and your IP address@endif. What exactly is stored? +
diff --git a/views/snippets/notifications-list.html b/views/snippets/notifications-list.html index da66c32..0f48815 100644 --- a/views/snippets/notifications-list.html +++ b/views/snippets/notifications-list.html @@ -2,7 +2,7 @@ @if(n.type === 'approve')
+
+
+