From 340c825019226e9052df88ad75d1eeb1b37a79a9 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 13 Sep 2026 04:05:59 +0200 Subject: [PATCH] fdsafs --- config_example.json | 29 + public/s/css/f0ckm.css | 242 +++++++- public/s/js/admin.js | 88 +-- public/s/js/anon_ssh.js | 158 ++++- public/s/js/comments.js | 10 +- public/s/js/f0ckm.js | 780 +++++++++++++++++++++--- public/s/js/scroller.js | 91 ++- public/s/js/sidebar-activity.js | 28 +- public/s/js/user.js | 100 +-- src/comment_upload_handler.mjs | 7 + src/inc/lib.mjs | 9 +- src/inc/locales/de.json | 6 + src/inc/locales/en.json | 6 + src/inc/locales/nl.json | 6 + src/inc/locales/zange.json | 6 + src/inc/routeinc/f0cklib.mjs | 185 ++++-- src/inc/routes/admin.mjs | 275 ++++++--- src/inc/routes/ajax.mjs | 5 +- src/inc/routes/apiv2/anon.mjs | 107 ++-- src/inc/routes/apiv2/index.mjs | 113 +++- src/inc/routes/apiv2/settings.mjs | 28 +- src/inc/routes/apiv2/tags.mjs | 7 + src/inc/routes/apiv2/upload.mjs | 20 +- src/inc/routes/banned.mjs | 20 +- src/inc/routes/comments.mjs | 20 +- src/inc/routes/index.mjs | 46 +- src/inc/routes/notifications.mjs | 135 +++- src/inc/routes/random.mjs | 8 +- src/inc/routes/scroller.mjs | 3 +- src/inc/routes/search.mjs | 4 + src/inc/routes/upload.mjs | 17 +- src/inc/routes/user_halls.mjs | 17 +- src/inc/routes/user_tags.mjs | 8 +- src/inc/security.mjs | 27 +- src/inc/settings.mjs | 94 +++ src/index.mjs | 14 +- src/upload_handler.mjs | 8 +- views/admin/users.html | 282 ++++++++- views/banned.html | 75 ++- views/index-partial.html | 2 +- views/item-partial-legacy.html | 20 +- views/item-partial-modern.html | 10 +- views/scroller.html | 47 +- views/snippets/excluded-tags-modal.html | 69 ++- views/snippets/footer.html | 13 +- views/snippets/header.html | 2 +- views/snippets/items-grid.html | 2 +- views/snippets/navbar.html | 10 +- 48 files changed, 2727 insertions(+), 532 deletions(-) diff --git a/config_example.json b/config_example.json index bde5259..3ed13a4 100644 --- a/config_example.json +++ b/config_example.json @@ -18,6 +18,7 @@ "invite_secret": "YOUR_SECRET_HERE", "hide_comments_from_public": false, "guest_anonymize": false, + "anon_anonymize": false, "timezone": "UTC", "development": true }, @@ -36,6 +37,34 @@ "allow_user_upload_visibility": true, "enable_item_slugs": true, "enable_anonymous_access": true, + "anonymous_permissions": { + "upload": false, + "comment": true, + "comment_attachments": false, + "comment_vote": true, + "poll_vote": true, + "tag": true, + "tag_vote": true, + "favorite": true, + "rate_item": false, + "filter": true, + "exclude_tags": true, + "anonymize_users": false, + "allowed_modes": [ + "sfw", + "nsfw", + "untagged", + "all", + "nsfl" + ], + "allowed_mimes": [ + "image", + "video", + "audio", + "flash", + "pdf" + ] + }, "onara": false, "nsfl_tag_id": 4, "allowedMimes": [ diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 2cf7380..75d8f35 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -7486,6 +7486,112 @@ a.removetag i { vertical-align: middle; } +/* Tag Badge & Hover-to-Exclude Action */ +.tag-badge { + display: inline-flex; + align-items: center; + position: relative; + transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + vertical-align: middle; +} + +.tag-badge a.tag-name { + color: inherit; + text-decoration: none; + transition: opacity 0.15s ease; +} + +.tag-badge .tag-exclude-btn { + background: transparent; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + line-height: 1; + vertical-align: middle; + border-radius: 50%; + outline: none; + transition: all 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@media (hover: hover) { + .tag-badge:not(.tag-is-excluded) .tag-exclude-btn { + opacity: 0; + width: 0; + max-width: 0; + margin-left: 0; + overflow: hidden; + transform: scale(0.5); + pointer-events: none; + } + + .tag-badge:hover .tag-exclude-btn, + .tag-badge:focus-within .tag-exclude-btn { + opacity: 0.85; + width: 14px; + max-width: 14px; + margin-left: 5px; + overflow: visible; + transform: scale(1); + pointer-events: auto; + } +} + +@media (hover: none) { + .tag-badge .tag-exclude-btn { + opacity: 0.7; + width: 14px; + max-width: 14px; + margin-left: 4px; + overflow: visible; + pointer-events: auto; + transform: scale(1); + } +} + +.tag-badge .tag-exclude-btn:hover, +.tag-badge .tag-exclude-btn:focus-visible { + opacity: 1; + color: #ff4757 !important; + transform: scale(1.28); + filter: drop-shadow(0 0 6px rgba(255, 71, 87, 0.75)); +} + +/* Excluded Tag State */ +.tag-badge.tag-is-excluded { + opacity: 0.75; + box-shadow: inset 0 0 0 1px rgba(255, 71, 87, 0.55), 0 0 8px rgba(255, 71, 87, 0.2); + background-color: rgba(255, 71, 87, 0.15) !important; +} + +.tag-badge.tag-is-excluded a.tag-name { + text-decoration: line-through; + text-decoration-color: rgba(255, 71, 87, 0.8); + text-decoration-thickness: 1.5px; + opacity: 0.85; +} + +.tag-badge.tag-is-excluded .tag-exclude-btn { + opacity: 0.95; + width: 14px; + max-width: 14px; + margin-left: 5px; + overflow: visible; + pointer-events: auto; + transform: scale(1); + color: #ff4757; +} + +.tag-badge.tag-is-excluded .tag-exclude-btn:hover { + color: #2ed573 !important; + filter: drop-shadow(0 0 6px rgba(46, 213, 115, 0.75)); +} + .badge-greentext { color: #789922; text-shadow: inherit !important; @@ -10106,6 +10212,26 @@ input#s_avatar { cursor: pointer; } +.nav-mime-item.locked { + cursor: not-allowed !important; + opacity: 0.85; +} + +.nav-mime-item.locked:hover { + background: transparent !important; + color: #ccc !important; +} + +.nav-mime-item.locked input[type="checkbox"] { + cursor: not-allowed !important; + pointer-events: none !important; +} + +.filter-pill.locked { + cursor: not-allowed !important; + opacity: 0.85; +} + .nav-mime-divider { height: 1px; background: rgba(255, 255, 255, 0.1); @@ -11715,23 +11841,19 @@ html[theme="f0ck95d"] .badge-dark { vertical-align: middle; text-decoration: none; cursor: pointer; - transition: color 0.15s ease, transform 0.12s cubic-bezier(0.2, 0, 0, 1), opacity 0.15s ease; + transition: opacity 0.15s ease; } .steuerung a:hover, .steuerung button:hover { - color: var(--accent); - opacity: 0.85; - transform: translateY(-1px); + opacity: 0.8; } .steuerung a:active, .steuerung button:active, .steuerung a.is-clicked, .steuerung button.is-clicked { - transform: scale(0.92) translateY(1px); - opacity: 0.65; - transition-duration: 0.05s; + opacity: 0.6; } .steuerung.steuerung-icon { @@ -11745,7 +11867,7 @@ html[theme="f0ck95d"] .badge-dark { align-items: center; justify-content: center; width: 1.6em; - transition: color 0.15s ease, transform 0.12s cubic-bezier(0.2, 0, 0, 1), opacity 0.15s ease; + transition: opacity 0.15s ease; background: none; border: none; padding: 0; @@ -11755,17 +11877,14 @@ html[theme="f0ck95d"] .badge-dark { .steuerung.steuerung-icon a:hover, .steuerung.steuerung-icon button:hover { - color: var(--accent); - transform: translateY(-1px); + opacity: 0.8; } .steuerung.steuerung-icon a:active, .steuerung.steuerung-icon button:active, .steuerung.steuerung-icon a.is-clicked, .steuerung.steuerung-icon button.is-clicked { - transform: scale(0.88) translateY(1px); - opacity: 0.65; - transition-duration: 0.05s; + opacity: 0.6; } html[theme='light'] .steuerung a, @@ -12503,6 +12622,11 @@ input:checked+.slider:before { grid-column: 1; } +.rating-selector.locked { + cursor: not-allowed; + opacity: 0.9; +} + .rating-toggle-btn { display: inline-block; background: transparent; @@ -12528,6 +12652,12 @@ input:checked+.slider:before { background: rgba(255, 255, 255, 0.08); } +.rating-toggle-btn.locked { + cursor: not-allowed !important; + pointer-events: none !important; + opacity: 0.85 !important; +} + /* Per-rating active colours */ .rating-toggle-btn.active[data-rating="sfw"] { background: #2e7d32; @@ -15670,6 +15800,41 @@ span.gchat-post-card--loading { gap: 5px; } +/* Excluded Tags Filter Modal Section */ +.excluded-tags-section-header { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 12px; +} + +.excluded-tags-section-title { + font-size: 1.05em; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + letter-spacing: 0.5px; + display: inline-flex; + align-items: center; + gap: 7px; +} + +.excluded-tags-section-title i { + color: #ff4757; + font-size: 0.9em; +} + +.excluded-tags-count-badge { + background: #ff4757; + color: #fff; + font-size: 0.72em; + font-weight: 700; + padding: 2px 7px; + border-radius: 12px; + line-height: 1.2; + box-shadow: 0 2px 6px rgba(255, 71, 87, 0.4); +} + #nav_excluded_tags_list { margin-bottom: 15px; display: flex; @@ -15677,6 +15842,57 @@ span.gchat-post-card--loading { gap: 8px; justify-content: center; max-width: 100%; + min-height: 28px; + align-items: center; +} + +.excluded-tag-chip { + padding: 5px 12px; + border-radius: 20px; + background: rgba(255, 71, 87, 0.12); + border: 1px solid rgba(255, 71, 87, 0.35); + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9em; + color: #f1f2f6; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.excluded-tag-chip:hover { + background: rgba(255, 71, 87, 0.2); + border-color: rgba(255, 71, 87, 0.55); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(255, 71, 87, 0.25); +} + +.excluded-tag-chip .remove-excluded-tag { + color: #ff4757; + text-decoration: none; + font-weight: bold; + font-size: 1.15em; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + transition: all 0.2s ease; + cursor: pointer; +} + +.excluded-tag-chip .remove-excluded-tag:hover { + color: #fff; + background: #ff4757; + transform: scale(1.18); +} + +.no-excluded-tags-hint { + color: rgba(255, 255, 255, 0.4); + font-size: 0.9em; + font-style: italic; } .nav-exclude { diff --git a/public/s/js/admin.js b/public/s/js/admin.js index 8e99b3e..4458f44 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -99,7 +99,7 @@ if (canManage) { span.classList.add('can-cycle'); } - } else if (tag.display_name || tag.user) { + } else if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) { span.setAttribute('tooltip', tag.display_name || tag.user); } @@ -226,6 +226,13 @@ const toggleFavEvent = async (e) => { + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) { + if (typeof window.flashMessage === 'function') { + window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error'); + } + return; + } const ctx = getContext(); if (!ctx) return; const { postid } = ctx; @@ -234,46 +241,57 @@ const favoBtn = document.querySelector("#a_favo"); const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid'); - const res = await post('/api/v2/togglefav', { - postid: postid - }); - if (res.success) { - if (window.invalidateItemCache) { - window.invalidateItemCache(postid); - } - // New state is the logical opposite of what it was before the API call - const isNowFav = !wasAlreadyFav; + try { + const res = await post('/api/v2/togglefav', { + postid: postid + }); + if (res && res.success) { + if (window.invalidateItemCache) { + window.invalidateItemCache(postid); + } + // New state is the logical opposite of what it was before the API call + const isNowFav = !wasAlreadyFav; - if (favoBtn) { - favoBtn.classList.toggle('fa-solid', isNowFav); - favoBtn.classList.toggle('fa-regular', !isNowFav); - } + if (favoBtn) { + favoBtn.classList.toggle('fa-solid', isNowFav); + favoBtn.classList.toggle('fa-regular', !isNowFav); + } - const favcontainer = document.querySelector('#favs'); - favcontainer.innerHTML = ""; - if (res.favs.length > 0) { - res.favs.forEach(f => { - const a = document.createElement('a'); - a.href = `/user/${f.user}`; - a.setAttribute('tooltip', f.display_name || f.user); - a.setAttribute('flow', 'up'); + const favcontainer = document.querySelector('#favs'); + favcontainer.innerHTML = ""; + if (res.favs && res.favs.length > 0) { + res.favs.forEach(f => { + const a = document.createElement('a'); + a.href = `/user/${f.user}`; + a.setAttribute('tooltip', f.display_name || f.user); + a.setAttribute('flow', 'up'); - const img = document.createElement('img'); - img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png'); - img.style.height = "32px"; - img.style.width = "32px"; - if (f.username_color) img.style.borderColor = f.username_color; + const img = document.createElement('img'); + img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png'); + img.style.height = "32px"; + img.style.width = "32px"; + if (f.username_color) img.style.borderColor = f.username_color; - a.appendChild(img); - favcontainer.appendChild(a); - }); - favcontainer.hidden = false; + a.appendChild(img); + favcontainer.appendChild(a); + }); + favcontainer.hidden = false; + } else { + favcontainer.hidden = true; + } + + window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES')); + if (navigator.vibrate) navigator.vibrate(50); } else { - favcontainer.hidden = true; + const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.'; + if (typeof window.flashMessage === 'function') { + window.flashMessage(errMsg, 3000, 'error'); + } + } + } catch (err) { + if (typeof window.flashMessage === 'function') { + window.flashMessage('Failed to update favorite.', 3000, 'error'); } - - window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES')); - if (navigator.vibrate) navigator.vibrate(50); } }; diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js index 67d81bc..e26202b 100644 --- a/public/s/js/anon_ssh.js +++ b/public/s/js/anon_ssh.js @@ -84,6 +84,9 @@ this.rawSeed = null; this.isSessionReady = false; this.hwFingerprint = null; + try { + this.hwFingerprint = localStorage.getItem('f0ck_anon_hw_fp') || null; + } catch (e) {} } /** @@ -92,6 +95,13 @@ */ async getHardwareFingerprint() { if (this.hwFingerprint) return this.hwFingerprint; + try { + const cached = localStorage.getItem('f0ck_anon_hw_fp'); + if (cached) { + this.hwFingerprint = cached; + return this.hwFingerprint; + } + } catch (e) {} try { let glVendor = ''; @@ -117,16 +127,67 @@ } } catch (e) {} + // WebGPU adapter architecture (modern GPUs) + let gpuArch = ''; + try { + if (navigator.gpu) { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter && adapter.info) { + gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':'); + } + } + } catch (e) {} + + // CPU & Memory const concurrency = navigator.hardwareConcurrency || 0; const memory = navigator.deviceMemory || 0; + const platform = navigator.platform || ''; + + // Display, Gamut & Dynamic Range const screenInfo = [ window.screen ? window.screen.width : 0, window.screen ? window.screen.height : 0, + window.screen ? window.screen.availWidth : 0, + window.screen ? window.screen.availHeight : 0, window.screen ? window.screen.colorDepth : 0, - window.screen ? window.screen.pixelDepth : 0, - window.devicePixelRatio || 1 + window.devicePixelRatio || 1, + window.matchMedia && window.matchMedia('(color-gamut: p3)').matches ? 'p3' : (window.matchMedia && window.matchMedia('(color-gamut: rec2020)').matches ? 'rec2020' : 'srgb'), + window.matchMedia && window.matchMedia('(dynamic-range: high)').matches ? 'hdr' : 'sdr' ].join('x'); + // Input & Peripheral Hardware (touch, mouse, stylus) + const touchPoints = (typeof navigator !== 'undefined' && 'maxTouchPoints' in navigator) ? navigator.maxTouchPoints : 0; + const pointerType = (window.matchMedia && window.matchMedia('(pointer: fine)').matches) ? 'fine' : ((window.matchMedia && window.matchMedia('(pointer: coarse)').matches) ? 'coarse' : 'none'); + const hoverType = (window.matchMedia && window.matchMedia('(hover: hover)').matches) ? 'hover' : 'none'; + const inputHardware = `${touchPoints}:${pointerType}:${hoverType}`; + + // 2D Canvas Font Rasterizer & Subpixel Geometry + let canvasFp = ''; + try { + const c2d = document.createElement('canvas'); + c2d.width = 240; + c2d.height = 60; + const ctx = c2d.getContext('2d'); + if (ctx) { + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#f60'; + ctx.fillRect(10, 5, 60, 20); + ctx.fillStyle = '#069'; + ctx.font = '14pt Arial, sans-serif'; + ctx.fillText('f0ck.dev 😃', 4, 35); + ctx.fillStyle = 'rgba(102, 204, 0, 0.7)'; + ctx.font = '16pt Times, serif'; + ctx.fillText('f0ck.dev 😃', 20, 48); + const imgData = ctx.getImageData(0, 0, 240, 60).data; + let sum = 0; + for (let i = 0; i < imgData.length; i += 4) { + sum = (sum * 31 + imgData[i] + imgData[i+1] + imgData[i+2] + imgData[i+3]) >>> 0; + } + canvasFp = sum.toString(16); + } + } catch (e) {} + + // Web Audio DSP Floating-Point Math let audioFp = ''; try { const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; @@ -159,14 +220,26 @@ } } catch (e) {} + // FPU Math Microarchitecture Precision + const mathPrecision = [ + Math.tan(-1e300).toString().substring(0, 10), + Math.sinh(1).toString().substring(0, 10), + Math.acos(0.123456789).toString().substring(0, 10) + ].join(';'); + const rawHardware = [ glVendor, glRenderer, glLimits, + gpuArch, concurrency, memory, + platform, screenInfo, - audioFp + inputHardware, + canvasFp, + audioFp, + mathPrecision ].join('~~~'); const encoder = new TextEncoder(); @@ -174,6 +247,9 @@ const hashBuf = await window.crypto.subtle.digest('SHA-256', data); const hashHex = bytesToHex(new Uint8Array(hashBuf)); this.hwFingerprint = `HW:${hashHex}`; + try { + localStorage.setItem('f0ck_anon_hw_fp', this.hwFingerprint); + } catch (e) {} return this.hwFingerprint; } catch (err) { console.warn('[ANON_SSH] Failed to compute hardware fingerprint:', err); @@ -483,7 +559,7 @@ localStorage.setItem(STORAGE_KEY_PUB, this.pubkey); localStorage.setItem(STORAGE_KEY_FP, this.fingerprint); - await this.ensureSession(true); + await this.ensureSession(true, isExplicitLogin); return this.getIdentity(); } @@ -514,7 +590,11 @@ /** * Authenticate to backend and establish/refresh anonymous session */ - async ensureSession(force = false) { + async ensureSession(force = false, isExplicitLogin = false) { + if (window.location.pathname === '/banned') { + return; + } + // If user is already logged in as a real registered user, don't overwrite session! if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) { return; @@ -550,8 +630,15 @@ reason: data.reason, expires: data.expires }); - if (window.location.pathname !== '/banned') { - window.location.href = data.redirect || '/banned'; + this.clearStoredIdentity(); + this.updateNavUI(false); + // ONLY redirect to /banned if user was explicitly trying to login! + if (isExplicitLogin) { + const anonModal = document.getElementById('anon-ssh-modal'); + if (anonModal) anonModal.style.display = 'none'; + if (window.location.pathname !== '/banned') { + window.location.href = data.redirect || '/banned'; + } } return; } @@ -594,6 +681,9 @@ setTombstone(tombstone) { try { localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(tombstone)); + if (tombstone && tombstone.banned) { + document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstone))}; Path=/; Max-Age=31536000; SameSite=Lax`; + } } catch (e) {} } @@ -653,6 +743,7 @@ localStorage.removeItem(STORAGE_KEY_PRIV); localStorage.removeItem(STORAGE_KEY_PUB); localStorage.removeItem(STORAGE_KEY_FP); + document.cookie = 'f0ck_banned=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; this.cryptoKey = null; this.rawSeed = null; this.rawPub = null; @@ -669,11 +760,27 @@ if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) { return; } + + // If client has a ban tombstone, redirect to /banned directly on login attempt + const tombstone = this.getTombstone(); + if (tombstone && tombstone.banned) { + const anonModal = document.getElementById('anon-ssh-modal'); + if (anonModal) anonModal.style.display = 'none'; + if (window.location.pathname !== '/banned') { + window.location.href = '/banned'; + } + return; + } + try { if (!this.pubkey) { - await this.generateIdentity(); + await this.generateIdentity(true); + } else { + await this.ensureSession(true, true); } - await this.ensureSession(true); + + if (!this.isSessionReady) return; + // Sync any guest favorites saved in localStorage to this anon account if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') { await window.f0ckGuestFavs.importToAccount(); @@ -698,12 +805,16 @@ this.clearStoredIdentity(); await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); } finally { - window.location.href = '/'; + this.updateNavUI(false); + if (typeof window.showToastNotification === 'function') { + window.showToastNotification('Logged out from anonymous session'); + } + window.location.reload(); } } /** - * Update visitor navbar UI elements + * Update navigation bar elements based on anonymous authentication state */ updateNavUI(isAnon) { const icon = document.getElementById('nav-visitor-icon'); @@ -765,8 +876,12 @@ * Initialize on page startup */ async init() { + if (window.location.pathname === '/banned') { + return; + } + if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) { - if (window.f0ckSession.is_anon) { + if (window.f0ckSession.is_anon && window.f0ckSession.logged_in) { await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); window.location.reload(); return; @@ -780,6 +895,15 @@ return; } + // If client has a ban tombstone, do NOT attempt background session handshake on page load/reload! + const tombstone = this.getTombstone(); + if (tombstone && tombstone.banned) { + this.clearStoredIdentity(); + this.updateNavUI(false); + this.attachUIListeners(); + return; + } + const storedPriv = localStorage.getItem(STORAGE_KEY_PRIV); const storedPub = localStorage.getItem(STORAGE_KEY_PUB); const storedFp = localStorage.getItem(STORAGE_KEY_FP); @@ -808,12 +932,12 @@ // User previously logged in as anonymous with this key // If backend session is already active, avoid redundant session churn - if (window.f0ckSession && window.f0ckSession.is_anon) { + if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) { this.isSessionReady = true; this.updateNavUI(true); } else { - await this.ensureSession(); - this.updateNavUI(true); + // Unauthenticated guest state: do NOT auto-login as anonymous on page load! + this.updateNavUI(false); } } catch (err) { console.warn('[ANON_SSH] Failed to restore stored key:', err); @@ -822,7 +946,7 @@ } } else { // Clean guest state! Do NOT auto-generate or establish session! - if (window.f0ckSession && window.f0ckSession.is_anon) { + if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) { // Stale backend anon session cookie without corresponding local key: clear cookie await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); window.location.reload(); @@ -853,7 +977,7 @@ } const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]'); - if (logoutTarget && ((window.f0ckSession && window.f0ckSession.is_anon) || this.pubkey)) { + if (logoutTarget && ((window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) || this.pubkey)) { e.preventDefault(); this.logoutAnonymous(); return; diff --git a/public/s/js/comments.js b/public/s/js/comments.js index 1442fa2..b12ebc4 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -2230,7 +2230,7 @@ class CommentSystem { const authorUserId = comment.user_id ?? (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? (window.f0ckSession.id || window.f0ckSession.user_id) : null); const authorUsernameColor = comment.username_color || (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? window.f0ckSession.username_color : null); - const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in; + const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in); if (isAnonGuest) bannerStyle = ''; const avatarHtml = isAnonGuest @@ -2366,7 +2366,8 @@ class CommentSystem { const counter = (maxLen !== null && maxLen !== undefined) ? `0 / ${maxLen}` : ''; - const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments; + const anonAttachmentsDisabled = session.is_anon && session.anon_permissions?.comment_attachments === false; + const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments && !anonAttachmentsDisabled; const multiFile = session.fileupload_comments_multifile; const attachBtn = fileUploadEnabled ? `` @@ -3350,6 +3351,11 @@ class CommentSystem { return; } if (submitBtn.classList.contains('loading') || submitBtn.disabled) return; + if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions?.comment === false) { + if (window.flashMessage) window.flashMessage('Anonymous commenting is disabled.', 3000, 'error'); + else alert('Anonymous commenting is disabled.'); + return; + } // ── Upload all staged files now (at submit time) ─────────────────────── const fileIds = []; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 5fe6f65..a389be6 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -12,6 +12,19 @@ } catch (_) {} })(); +if (typeof window.f0ckDebug !== 'function') { + window.f0ckDebug = (...args) => { + if (window.f0ckSession?.development) console.log(...args); + }; +} + +window.getCsrfToken = () => { + return (window.f0ckSession && window.f0ckSession.csrf_token) || + document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || + document.querySelector('input[name="csrf_token"]')?.value || + ''; +}; + window.requestAnimFrame = (function () { return window.requestAnimationFrame @@ -75,14 +88,23 @@ window.cancelAnimFrame = (function () { window.syncGuestFavoIcon = syncGuestFavoIcon; document.addEventListener('click', e => { - if (window.f0ckSession && window.f0ckSession.user) return; const target = e.target.nodeType === 3 ? e.target.parentElement : e.target; const favoBtn = target.closest('#a_favo'); if (!favoBtn) return; + if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + if (typeof window.flashMessage === 'function') { + window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error'); + } + return; + } + if (window.f0ckSession && window.f0ckSession.user) return; e.preventDefault(); e.stopPropagation(); if (typeof window.flashMessage === 'function') { - window.flashMessage('Login to favorite posts'); + window.flashMessage('Login to favorite posts', 3000, 'warning'); } }); @@ -549,25 +571,56 @@ window.cancelAnimFrame = (function () { let audioSource = null; const updateMimeLabel = () => { - let mimeStr = null; - const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); - if (cookieMime) { + const isAnon = !!(window.f0ckSession?.is_anon); + const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes; + const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1; + const singleMime = isSingleMime ? allowedMimes[0] : null; + + let selected = []; + if (isSingleMime) { + selected = [singleMime]; + const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); + const val = cookieMime ? cookieMime.split('=')[1] : null; + if (val !== singleMime) { + document.cookie = `mime=${singleMime}; path=/; max-age=31536000; SameSite=Lax`; + } + } else { + let mimeStr = null; + const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); + if (cookieMime) { mimeStr = cookieMime.split('=')[1]; + } + selected = mimeStr ? mimeStr.split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m)) : []; + if (isAnon && Array.isArray(allowedMimes) && allowedMimes.length > 0) { + selected = selected.filter(m => allowedMimes.includes(m)); + } } - const selected = mimeStr ? mimeStr.split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m)) : []; - document.querySelectorAll('.nav-mime-btn').forEach(btn => { let label = 'ALL'; if (selected.length > 0) { label = selected.map(s => s.charAt(0).toUpperCase()).sort().join(','); } btn.innerHTML = `${label} ▾`; + if (isSingleMime) { + btn.classList.add('locked'); + } }); document.querySelectorAll('.nav-mime-menu').forEach(menu => { menu.querySelectorAll('input[type="checkbox"]').forEach(cb => { - cb.checked = selected.includes(cb.value); + if (isSingleMime) { + cb.checked = (cb.value === singleMime); + cb.disabled = true; + const label = cb.closest('label') || cb.parentElement; + if (label) { + label.classList.add('locked'); + label.style.cursor = 'not-allowed'; + label.title = 'Locked by site permissions'; + } + } else { + cb.checked = selected.includes(cb.value); + } }); }); @@ -852,6 +905,30 @@ window.cancelAnimFrame = (function () { } const selector = document.getElementById('rating-selector'); if (!selector) return; + const lockedRating = selector.dataset.lockedRating; + if (lockedRating) { + selector.querySelectorAll('.rating-toggle-btn').forEach(btn => { + if (btn.dataset.rating === lockedRating) { + btn.classList.add('active', 'locked'); + btn.disabled = true; + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.85'; + if (!btn.querySelector('.fa-lock')) { + const lockIcon = document.createElement('i'); + lockIcon.className = 'fa-solid fa-lock'; + lockIcon.style.marginLeft = '5px'; + lockIcon.style.fontSize = '0.75em'; + lockIcon.style.opacity = '0.7'; + btn.appendChild(lockIcon); + } + } else { + btn.classList.remove('active'); + btn.disabled = true; + btn.style.display = 'none'; + } + }); + return; + } selector.querySelectorAll('.rating-toggle-btn').forEach(btn => { const r = btn.dataset.rating; if (!r) return; // ALL button handled separately @@ -868,6 +945,11 @@ window.cancelAnimFrame = (function () { document.addEventListener('click', (e) => { const btn = e.target.closest('.rating-toggle-btn'); if (!btn) return; + if (btn.disabled || btn.classList.contains('locked') || btn.closest('#rating-selector[data-locked-rating]') || btn.closest('#rating-selector.locked')) { + e.preventDefault(); + e.stopPropagation(); + return; + } e.preventDefault(); e.stopPropagation(); @@ -1649,6 +1731,11 @@ window.cancelAnimFrame = (function () { } const json = await res.json(); + if (json && json.banned) { + if (loginModal) closeModal(loginModal); + window.location.href = json.redirect || '/banned'; + return; + } if (json && json.success === false) { let errDiv = loginForm.querySelector('.flash-error'); if (!errDiv) { @@ -4968,15 +5055,16 @@ window.cancelAnimFrame = (function () { const urlParams = new URLSearchParams(window.location.search); const qMime = urlParams.get('mime'); - if (qMime) params.append('mime', qMime); - else { + const cookieMimeMatch = document.cookie.match(/(?:^|;\s*)mime=([^;]*)/); + const cookieMime = cookieMimeMatch ? decodeURIComponent(cookieMimeMatch[1]).trim() : null; + + if (qMime !== null && qMime !== '') { + params.append('mime', qMime); + } else if (cookieMime !== null && cookieMime !== '') { + params.append('mime', cookieMime); + } else { const wMimeMatch = window.location.href.match(/\/((?:video|audio|image|,)+)(\/|$|\?)/); if (wMimeMatch) params.append('mime', wMimeMatch[1]); - else { - // Fallback to cookie - const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); - if (cookieMime) params.append('mime', cookieMime.split('=')[1]); - } } const isStrict = window.f0ckSession?.strict_mode || window.location.search.includes('strict=1') || (localStorage.getItem('search_strict') === 'true'); @@ -5066,6 +5154,24 @@ window.cancelAnimFrame = (function () { .catch(() => {}); } } + } else if (params.has('tag') || params.has('hall') || params.has('user') || params.has('userHall')) { + // Context had no matching items with the active MIME filter — try global random with the same filter + const fallbackParams = new URLSearchParams(); + const effectiveMime = params.get('mime'); + if (effectiveMime) fallbackParams.append('mime', effectiveMime); + if (params.get('strict')) fallbackParams.append('strict', '1'); + const fallbackUrl = '/api/v2/random' + ([...fallbackParams].length > 0 ? ('?' + fallbackParams.toString()) : ''); + fetch(fallbackUrl) + .then(r => r.json()) + .then(fbData => { + if (fbData.success && fbData.items && (fbData.items.slug || fbData.items.id)) { + const targetKey = fbData.items.slug || fbData.items.id; + loadItemAjax(`/${targetKey}`, true, { transition: 'fade-zoom' }); + } else { + window.location.href = link.href; + } + }) + .catch(() => { window.location.href = link.href; }); } else { window.location.href = link.href; } @@ -5537,13 +5643,17 @@ window.cancelAnimFrame = (function () { delete window._ratingDebounceTimers[postid]; const targetRating = ratingEl.dataset.rating || nextRating; + const csrf = (typeof window.getCsrfToken === 'function') + ? window.getCsrfToken() + : ((window.f0ckSession && window.f0ckSession.csrf_token) || document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); + fetch(`/api/v2/item/${postid}/rating`, { method: 'POST', headers: { "Content-Type": "application/json", - "X-CSRF-Token": window.f0ckSession?.csrf_token + "X-CSRF-Token": csrf }, - body: JSON.stringify({ rating: targetRating }) + body: JSON.stringify({ rating: targetRating, csrf_token: csrf }) }) .then(r => r.json()) .then(res => { @@ -5676,7 +5786,23 @@ window.cancelAnimFrame = (function () { // const clickOnElementBinding = selector => () => (elem = document.querySelector(selector)) ? elem.click() : null; - const clickOnNavBinding = selector => () => { const el = document.querySelector(selector); if (el && el.href && !el.href.endsWith('#')) el.click(); }; + const clickOnNavBinding = (directionOrSelector) => () => { + let el; + if (directionOrSelector === 'prev' || directionOrSelector === '#prev') { + el = document.querySelector(".steuerung .nav-prev:not([href='#']), .nav-prev:not([href='#']), #prev:not([href='#'])") || document.querySelector('.nav-prev') || document.getElementById('prev'); + } else if (directionOrSelector === 'next' || directionOrSelector === '#next') { + el = document.querySelector(".steuerung .nav-next:not([href='#']), .nav-next:not([href='#']), #next:not([href='#'])") || document.querySelector('.nav-next') || document.getElementById('next'); + } else { + el = document.querySelector(directionOrSelector); + } + if (el && el.href && !el.href.endsWith('#')) { + if (typeof window.triggerSteuerungFeedback === 'function') { + const steuerungBtn = document.querySelector(directionOrSelector === 'prev' || directionOrSelector === '#prev' ? '.steuerung .nav-prev' : '.steuerung .nav-next') || el; + window.triggerSteuerungFeedback(steuerungBtn); + } + el.click(); + } + }; const seekToPercentage = fraction => { const mediaElement = document.querySelector('#my-video') || document.querySelector('audio#my-video'); if (mediaElement && isFinite(mediaElement.duration)) { @@ -5694,11 +5820,19 @@ window.cancelAnimFrame = (function () { "7": () => seekToPercentage(0.7), "8": () => seekToPercentage(0.8), "9": () => seekToPercentage(0.9), - "ArrowLeft": clickOnNavBinding("#prev"), - "a": clickOnNavBinding("#prev"), - "ArrowRight": clickOnNavBinding("#next"), - "d": clickOnNavBinding("#next"), - "r": clickOnElementBinding("#random, #nav-random"), + "ArrowLeft": clickOnNavBinding("prev"), + "a": clickOnNavBinding("prev"), + "ArrowRight": clickOnNavBinding("next"), + "d": clickOnNavBinding("next"), + "r": () => { + const el = document.querySelector(".steuerung #random, #random, #nav-random"); + if (el) { + if (typeof window.triggerSteuerungFeedback === 'function') { + window.triggerSteuerungFeedback(el); + } + el.click(); + } + }, "p": (e) => { if (e && e.preventDefault) e.preventDefault(); const ratingEl = document.querySelector('.rating-tag.can-cycle, button#a_toggle'); @@ -6444,6 +6578,9 @@ window.cancelAnimFrame = (function () { // Remove trailing commas and extra whitespace val = val.replace(/,+$/, '').trim(); if (val) { + if (window.f0ckInterestEngine?.recordTagSearch) { + window.f0ckInterestEngine.recordTagSearch(val); + } toggleSearch(false); let targetUrl = `/tag/${encodeURIComponent(val).replace(/%2C/g, ',').replace(/%20/g, ' ')}`; // Use AJAX for faster transition and clean URL @@ -6493,6 +6630,9 @@ window.cancelAnimFrame = (function () { div.addEventListener('mousedown', (e) => { e.preventDefault(); + if (window.f0ckInterestEngine?.recordTagSearch) { + window.f0ckInterestEngine.recordTagSearch(s.tag); + } const isStrict = strict && strict.checked; if (isStrict) { const parts = input.value.split(','); @@ -6767,8 +6907,6 @@ window.cancelAnimFrame = (function () { } }; const initExcludedTagsModal = () => { - const isLoggedIn = !!(window.f0ckSession && window.f0ckSession.logged_in); - const overlay = document.getElementById('excluded-tags-overlay'); if (!overlay) return; @@ -6779,59 +6917,251 @@ window.cancelAnimFrame = (function () { const suggestions = document.getElementById('nav_exclude_suggestions'); const filterBtn = document.getElementById('nav-filter-btn'); + const canExcludeTags = () => { + if (!window.f0ckSession) return false; + if (window.f0ckSession.logged_in && !window.f0ckSession.is_anon) return true; + if (window.f0ckSession.enable_anonymous_access === false) return false; + const perms = window.f0ckSession.anon_permissions || {}; + return perms.exclude_tags !== false && perms.filter !== false; + }; + + const ensureSessionForExclude = async () => { + if (window.f0ckSession?.logged_in) return true; + if (canExcludeTags() && window.f0ckAnonSSH) { + try { + if (!window.f0ckAnonSSH.isSessionReady) { + if (!window.f0ckAnonSSH.pubkey) { + await window.f0ckAnonSSH.generateIdentity(true); + } + await window.f0ckAnonSSH.ensureSession(true); + } + if (window.f0ckSession) { + window.f0ckSession.logged_in = true; + window.f0ckSession.is_anon = true; + } + return true; + } catch (e) { + console.warn('[excluded_tags] Anon auto-session failed:', e); + } + } + return false; + }; + const toggleModal = (show) => { + if (show && window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions?.filter === false) { + return; + } if (show) { overlay.style.display = 'flex'; overlay.offsetHeight; overlay.classList.add('visible'); document.body.style.overflow = 'hidden'; - if (isLoggedIn) renderTags(); + if (canExcludeTags()) renderTags(); if (window.syncRatingButtonUI) window.syncRatingButtonUI(); - if (isLoggedIn && window.innerWidth > 768) input.focus(); + if (input && canExcludeTags() && window.innerWidth > 768) input.focus(); } else { overlay.classList.remove('visible'); document.body.style.overflow = ''; - suggestions.style.display = 'none'; + if (suggestions) suggestions.style.display = 'none'; setTimeout(() => { overlay.style.display = 'none'; }, 200); } }; - const renderTags = async () => { - try { - const res = await fetch('/api/v2/settings/excluded_tags'); - const data = await res.json(); - if (data.success) { - list.innerHTML = ''; - if (data.tags.length === 0) { - list.innerHTML = `${(window.f0ckI18n && window.f0ckI18n.no_tags_excluded) || 'No tags excluded'}`; - } - data.tags.forEach(tag => { + const countBadge = document.getElementById('nav_excluded_tags_count'); + + const syncExcludedTagsUI = (tags = []) => { + if (!Array.isArray(tags)) tags = []; + window.f0ckSession = window.f0ckSession || {}; + window.f0ckSession.excluded_tags = tags.map(t => t.id); + window.f0ckSession.excluded_tag_objects = tags; + + // 1. Update filter modal list + if (list) { + list.innerHTML = ''; + if (tags.length === 0) { + list.innerHTML = `${(window.f0ckI18n && window.f0ckI18n.no_tags_excluded) || 'No tags excluded yet'}`; + } else { + tags.forEach(tag => { const span = document.createElement('span'); - span.className = 'badge badge-secondary'; - span.style.cssText = 'padding: 5px 12px; border-radius: 20px; background: rgba(255,255,255,0.1); display: flex; align-items: center; gap: 8px; font-size: 0.9em;'; + span.className = 'excluded-tag-chip'; + + const nameSpan = document.createElement('span'); + nameSpan.textContent = tag.tag; + const removeLink = document.createElement('a'); removeLink.href = '#'; removeLink.className = 'remove-excluded-tag'; removeLink.dataset.tag = tag.normalized; - removeLink.style.color = '#ff4444'; - removeLink.style.textDecoration = 'none'; - removeLink.style.fontWeight = 'bold'; - removeLink.style.fontSize = '1.2em'; - removeLink.style.lineHeight = '1'; + removeLink.dataset.tagId = tag.id; + removeLink.dataset.tagName = tag.tag; + removeLink.title = 'Remove from excluded tags'; removeLink.innerHTML = '×'; - span.textContent = tag.tag + ' '; + span.appendChild(nameSpan); span.appendChild(removeLink); list.appendChild(span); }); } + } + + // 2. Update count badge in modal + if (countBadge) { + if (tags.length > 0) { + countBadge.textContent = tags.length; + countBadge.style.display = 'inline-block'; + } else { + countBadge.style.display = 'none'; + } + } + + // 3. Update all tag badges on the current page + const tagBadges = document.querySelectorAll('.tag-badge'); + const excludedIds = new Set(tags.map(t => t.id)); + const excludedNorms = new Set(tags.map(t => t.normalized)); + + tagBadges.forEach(badge => { + const bId = parseInt(badge.getAttribute('data-tag-id'), 10); + const bNorm = badge.getAttribute('data-tag-normalized') || ''; + const isExcluded = (bId && excludedIds.has(bId)) || (bNorm && excludedNorms.has(bNorm)); + + const btn = badge.querySelector('.tag-exclude-btn'); + if (isExcluded) { + badge.classList.add('tag-is-excluded'); + if (btn) { + btn.title = (window.f0ckI18n && window.f0ckI18n.unexclude_tag) || 'Excluded (click to unexclude)'; + btn.innerHTML = ''; + } + } else { + badge.classList.remove('tag-is-excluded'); + if (btn) { + btn.title = (window.f0ckI18n && window.f0ckI18n.exclude_tag) || 'Exclude tag'; + btn.innerHTML = ''; + } + } + }); + }; + window.syncExcludedTagsUI = syncExcludedTagsUI; + + const renderTags = async () => { + if (!canExcludeTags()) return; + try { + const res = await fetch('/api/v2/settings/excluded_tags'); + if (!res.ok) return; + const data = await res.json(); + if (data.success) { + syncExcludedTagsUI(data.tags); + } } catch (e) { console.error('Failed to load excluded tags', e); } }; + window.toggleExcludeTag = async ({ tagId, tag, normalized }) => { + if (!canExcludeTags()) { + if (!window.f0ckSession || !window.f0ckSession.logged_in) { + window.flashMessage('Please log in to exclude tags', 3000, 'info'); + } else { + window.flashMessage('Tag exclusion is disabled by site permissions', 3000, 'error'); + } + return; + } + + if (!window.f0ckSession?.logged_in) { + const authed = await ensureSessionForExclude(); + if (!authed) { + window.flashMessage('Please log in to exclude tags', 3000, 'info'); + return; + } + } + + const currentIds = window.f0ckSession.excluded_tags || []; + const currentObjs = window.f0ckSession.excluded_tag_objects || []; + const isCurrentlyExcluded = (tagId && currentIds.includes(tagId)) || + currentObjs.some(t => t.id === tagId || t.normalized === normalized) || + document.querySelector(`.tag-badge[data-tag-normalized="${normalized}"]`)?.classList.contains('tag-is-excluded'); + + const csrf = (typeof window.getCsrfToken === 'function') + ? window.getCsrfToken() + : ((window.f0ckSession && window.f0ckSession.csrf_token) || document.querySelector('input[name="csrf_token"]')?.value || ''); + + if (isCurrentlyExcluded) { + try { + const res = await fetch(`/api/v2/settings/excluded_tags/${encodeURIComponent(normalized || tagId || tag)}`, { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrf } + }); + const data = await res.json(); + if (data.success) { + syncExcludedTagsUI(data.tags); + if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear(); + const msgTpl = window.f0ckI18n?.tag_unexcluded_msg || 'Tag "{tag}" removed from excluded tags'; + window.flashMessage(msgTpl.replace('{tag}', tag || normalized), 2500, 'info'); + } else { + window.flashMessage(data.msg || 'Error removing excluded tag', 3000, 'error'); + } + } catch (e) { + console.error(e); + window.flashMessage('Failed to update excluded tags', 3000, 'error'); + } + } else { + try { + const res = await fetch('/api/v2/settings/excluded_tags', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf + }, + body: JSON.stringify({ + tagname: normalized || tag, + tag_id: tagId, + csrf_token: csrf + }) + }); + const data = await res.json(); + if (data.success) { + syncExcludedTagsUI(data.tags); + if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear(); + const msgTpl = window.f0ckI18n?.tag_excluded_msg || 'Tag "{tag}" added to excluded tags'; + window.flashMessage(msgTpl.replace('{tag}', tag || normalized), 2500, 'info'); + } else { + window.flashMessage(data.msg || 'Error excluding tag', 3000, 'error'); + } + } catch (e) { + console.error(e); + window.flashMessage('Failed to exclude tag', 3000, 'error'); + } + } + }; + + // Delegated click listener for hover-to-exclude buttons on tags + document.addEventListener('click', (e) => { + const btn = e.target.closest('.tag-exclude-btn'); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + const badge = btn.closest('.tag-badge'); + if (!badge) return; + const tagId = parseInt(badge.getAttribute('data-tag-id'), 10) || null; + const tag = badge.getAttribute('data-tag') || badge.querySelector('.tag-name')?.textContent?.trim() || ''; + const normalized = badge.getAttribute('data-tag-normalized') || tag; + if (typeof window.toggleExcludeTag === 'function') { + window.toggleExcludeTag({ tagId, tag, normalized }); + } + }); + + document.addEventListener('f0ck:contentLoaded', () => { + if (typeof window.syncExcludedTagsUI === 'function' && window.f0ckSession?.excluded_tag_objects) { + window.syncExcludedTagsUI(window.f0ckSession.excluded_tag_objects); + } + }); + + // Initial background sync for allowed sessions + if (canExcludeTags()) { + renderTags(); + } + if (filterBtn) { filterBtn.addEventListener('click', (e) => { e.preventDefault(); @@ -6852,13 +7182,29 @@ window.cancelAnimFrame = (function () { if (e.target === overlay) toggleModal(false); }); - if (isLoggedIn) { + if (input && list) { const addTag = async () => { const tagname = input.value.trim(); if (!tagname) return; - suggestions.style.display = 'none'; + if (suggestions) suggestions.style.display = 'none'; + + if (!canExcludeTags()) { + window.flashMessage('Tag exclusion is disabled by site permissions', 3000, 'error'); + return; + } + + if (!window.f0ckSession?.logged_in) { + const authed = await ensureSessionForExclude(); + if (!authed) { + window.flashMessage('Please log in to exclude tags', 3000, 'info'); + return; + } + } + try { - const csrf = window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || ''; + const csrf = (typeof window.getCsrfToken === 'function') + ? window.getCsrfToken() + : ((window.f0ckSession && window.f0ckSession.csrf_token) || document.querySelector('input[name="csrf_token"]')?.value || ''); const res = await fetch('/api/v2/settings/excluded_tags', { method: 'POST', headers: { @@ -6869,9 +7215,11 @@ window.cancelAnimFrame = (function () { }); const data = await res.json(); if (data.success) { - renderTags(); + syncExcludedTagsUI(data.tags); input.value = ''; if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear(); + const msgTpl = window.f0ckI18n?.tag_excluded_msg || 'Tag {tag} added to excluded tags'; + window.flashMessage(msgTpl.replace('{tag}', tagname), 2500, 'info'); } else { window.flashMessage(data.msg || 'Error adding tag', 3000, 'error'); } @@ -6881,6 +7229,7 @@ window.cancelAnimFrame = (function () { if (window.innerWidth > 768) input.focus(); } }; + input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); @@ -6897,8 +7246,25 @@ window.cancelAnimFrame = (function () { if (e.target.classList.contains('remove-excluded-tag')) { e.preventDefault(); const tag = e.target.getAttribute('data-tag'); + const tagName = e.target.getAttribute('data-tag-name') || tag; + + if (!canExcludeTags()) { + window.flashMessage('Tag exclusion is disabled by site permissions', 3000, 'error'); + return; + } + + if (!window.f0ckSession?.logged_in) { + const authed = await ensureSessionForExclude(); + if (!authed) { + window.flashMessage('Please log in to exclude tags', 3000, 'info'); + return; + } + } + try { - const csrf = window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || ''; + const csrf = (typeof window.getCsrfToken === 'function') + ? window.getCsrfToken() + : ((window.f0ckSession && window.f0ckSession.csrf_token) || document.querySelector('input[name="csrf_token"]')?.value || ''); const res = await fetch(`/api/v2/settings/excluded_tags/${encodeURIComponent(tag)}`, { method: 'DELETE', headers: { @@ -6907,8 +7273,10 @@ window.cancelAnimFrame = (function () { }); const data = await res.json(); if (data.success) { - renderTags(); + syncExcludedTagsUI(data.tags); if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear(); + const msgTpl = window.f0ckI18n?.tag_unexcluded_msg || 'Tag {tag} removed from excluded tags'; + window.flashMessage(msgTpl.replace('{tag}', tagName), 2500, 'info'); } else { window.flashMessage(data.msg || 'Error removing tag', 3000, 'error'); } @@ -8587,8 +8955,16 @@ class NotificationSystem { this.es.close(); } + let sseUrl = `/api/notifications/stream?tabId=${this.tabId}`; + try { + const fp = localStorage.getItem('f0ck_anon_ssh_fp'); + if (fp) sseUrl += `&fp=${encodeURIComponent(fp)}`; + const hw = localStorage.getItem('f0ck_anon_hw_fp') || window.f0ckAnonSSH?.hwFingerprint; + if (hw) sseUrl += `&hw=${encodeURIComponent(hw)}`; + } catch (e) {} + window.f0ckDebug(`[NotificationSystem] Initializing SSE connection (tabId: ${this.tabId})...`); - this.es = new EventSource(`/api/notifications/stream?tabId=${this.tabId}`); + this.es = new EventSource(sseUrl); this.es.onopen = () => { window.f0ckDebug("[NotificationSystem] SSE connection established"); @@ -8601,6 +8977,23 @@ class NotificationSystem { try { const data = JSON.parse(e.data); window.f0ckDebug(`[SSE] Received message:`, data.type); + if (data.type === 'banned') { + window.f0ckDebug(`[SSE] Live ban event received:`, data.data); + if (window.f0ckAnonSSH && typeof window.f0ckAnonSSH.setTombstone === 'function') { + window.f0ckAnonSSH.setTombstone({ + banned: true, + reason: data.data?.reason || 'Violation of community guidelines', + expires: data.data?.expires || null + }); + window.f0ckAnonSSH.clearStoredIdentity(); + } + try { + const tombstoneData = { banned: true, reason: data.data?.reason, expires: data.data?.expires }; + document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstoneData))}; Path=/; Max-Age=31536000; SameSite=Lax`; + } catch (err) {} + window.location.href = data.data?.redirect || '/banned'; + return; + } if (data.type === 'notify') { this.pollDebounced(); const dnd = window.f0ckSession?.do_not_disturb === true; @@ -8900,7 +9293,7 @@ class NotificationSystem { aUsername.textContent = display_name || userName; } // Grid thumbnails: update data-user attribute (shown as CSS content on hover) - if (!window.f0ckSession?.guest_anonymize) { + if (!(window.f0ckSession?.is_anonymized ?? window.f0ckSession?.guest_anonymize)) { document.querySelectorAll('.thumb[data-user]').forEach(el => { const cur = el.getAttribute('data-user'); if (cur === userName || (oldDisplayName && cur === oldDisplayName)) { @@ -9990,12 +10383,13 @@ class NotificationSystem { // Generate new tags HTML if (Array.isArray(data.tags)) { // Cast to boolean to handle potentially numeric truthy values (1/0) - const isAdminBySession = !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator); - const hasSession = !!window.f0ckSession; + const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? window.f0ckSession?.is_anon); + const isAdminBySession = !isAnonymized && !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator); + const hasSession = !isAnonymized && !!(window.f0ckSession && window.f0ckSession.logged_in && !window.f0ckSession.is_anon); const canManageItem = !!( document.querySelector('#tags[data-can-manage="true"]') || isAdminBySession || - (window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase()) + (!isAnonymized && window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase()) ); // Deduplicate rating tags: guarantee at most one rating tag is kept @@ -10029,7 +10423,7 @@ class NotificationSystem { if (canManageItem) { span.classList.add('can-cycle'); } - } else if (hasSession) { + } else if (hasSession && (tag.display_name || tag.user)) { span.setAttribute('tooltip', tag.display_name || tag.user); } @@ -10038,12 +10432,29 @@ class NotificationSystem { contentEl = document.createElement('span'); contentEl.className = 'rating-label'; contentEl.textContent = tag.tag; + span.appendChild(contentEl); } else { + span.classList.add('tag-badge'); + span.setAttribute('data-tag-id', tag.id); + span.setAttribute('data-tag', tag.tag); + span.setAttribute('data-tag-normalized', tag.normalized); + const isExcl = Array.isArray(window.f0ckSession?.excluded_tags) && window.f0ckSession.excluded_tags.includes(tag.id); + if (isExcl) span.classList.add('tag-is-excluded'); + contentEl = document.createElement('a'); contentEl.href = `/tag/${tag.normalized}`; + contentEl.className = 'tag-name'; contentEl.textContent = tag.tag; + span.appendChild(contentEl); + + const exclBtn = document.createElement('button'); + exclBtn.type = 'button'; + exclBtn.className = 'tag-exclude-btn'; + exclBtn.title = isExcl ? ((window.f0ckI18n && window.f0ckI18n.unexclude_tag) || 'Excluded (click to unexclude)') : ((window.f0ckI18n && window.f0ckI18n.exclude_tag) || 'Exclude tag'); + exclBtn.setAttribute('aria-label', 'Exclude tag'); + exclBtn.innerHTML = ``; + span.appendChild(exclBtn); } - span.appendChild(contentEl); if (isAdminBySession && !isRating) { // Match template exactly:   @@ -10568,6 +10979,13 @@ document.addEventListener("DOMContentLoaded", function () { // Multi-Select MIME Logic const handleMimeChange = (menu) => { + const isAnon = !!(window.f0ckSession?.is_anon); + const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes; + const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1; + if (isSingleMime) { + if (window.updateMimeLabel) window.updateMimeLabel(); + return; + } const checked = Array.from(menu.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value); // Construct new state @@ -10634,6 +11052,15 @@ document.addEventListener("DOMContentLoaded", function () { document.querySelectorAll('.nav-mime-menu').forEach(menu => { menu.addEventListener('change', (e) => { + const isAnon = !!(window.f0ckSession?.is_anon); + const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes; + const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1; + if (isSingleMime) { + e.preventDefault(); + e.stopPropagation(); + if (window.updateMimeLabel) window.updateMimeLabel(); + return; + } if (e.target.type === 'checkbox') { handleMimeChange(menu); } @@ -10641,12 +11068,31 @@ document.addEventListener("DOMContentLoaded", function () { // Prevent menu closure on click inside menu.addEventListener('click', (e) => { - e.stopPropagation(); + const isAnon = !!(window.f0ckSession?.is_anon); + const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes; + const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1; + if (isSingleMime) { + const item = e.target.closest('.nav-mime-item, input[type="checkbox"]'); + if (item) { + e.preventDefault(); + e.stopPropagation(); + return; + } + } + e.stopPropagation(); }); }); document.querySelectorAll('.nav-mime-clear').forEach(btn => { btn.addEventListener('click', (e) => { + const isAnon = !!(window.f0ckSession?.is_anon); + const allowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes; + const isSingleMime = isAnon && Array.isArray(allowedMimes) && allowedMimes.length === 1; + if (isSingleMime) { + e.preventDefault(); + e.stopPropagation(); + return; + } const menu = btn.closest('.nav-mime-menu'); menu.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false); handleMimeChange(menu); @@ -12458,47 +12904,112 @@ document.addEventListener('DOMContentLoaded', () => { // ── Steuerung haptic & sound feedback ───────────────────────────────────────── (function() { let _audioCtx = null; + let _clickBuffer = null; + + function getAudioContext() { + if (_audioCtx && _audioCtx.state !== 'closed') return _audioCtx; + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return null; + try { + _audioCtx = new AudioCtx(); + } catch (e) { + return null; + } + return _audioCtx; + } + + // Pre-synthesize the mechanical tactile click into an AudioBuffer + // so rapid clicks never drop due to oscillator scheduling or ramp cutoff + function createClickBuffer(ctx) { + try { + const sampleRate = ctx.sampleRate || 44100; + const duration = 0.035; + const length = Math.ceil(sampleRate * duration); + const buf = ctx.createBuffer(1, length, sampleRate); + const data = buf.getChannelData(0); + + let oscPhase = 0; + let snapPhase = 0; + for (let i = 0; i < length; i++) { + const t = i / sampleRate; + // Warm body oscillator: 420Hz sweeping down to 110Hz exponentially over 24ms + const oscFreq = t < 0.024 ? 420 * Math.pow(110 / 420, t / 0.024) : 110; + oscPhase += (2 * Math.PI * oscFreq) / sampleRate; + let oscGain = 0; + if (t <= 0.0015) { + oscGain = t / 0.0015; + } else if (t <= 0.024) { + oscGain = Math.pow(0.0001, (t - 0.0015) / 0.0225); + } + const oscVal = Math.sin(oscPhase) * oscGain; + + // Subtle transient snap layer: 1200Hz down to 320Hz triangle wave over 8ms + const snapFreq = t < 0.008 ? 1200 * Math.pow(320 / 1200, t / 0.008) : 320; + snapPhase += snapFreq / sampleRate; + const normSnapPhase = snapPhase - Math.floor(snapPhase); + const snapTri = 4 * Math.abs(normSnapPhase - 0.5) - 1; + let snapGain = 0; + if (t <= 0.001) { + snapGain = (t / 0.001) * 0.3; + } else if (t <= 0.009) { + snapGain = 0.3 * Math.pow(0.0001 / 0.3, (t - 0.001) / 0.008); + } + const snapVal = snapTri * snapGain; + + data[i] = (oscVal + snapVal) * 0.15; + } + return buf; + } catch (e) { + return null; + } + } function playSteuerungClickSound() { try { - const AudioCtx = window.AudioContext || window.webkitAudioContext; - if (!AudioCtx) return; - if (!_audioCtx) { - _audioCtx = new AudioCtx(); + const ctx = getAudioContext(); + if (!ctx) return; + if (ctx.state === 'suspended') { + ctx.resume().catch(() => {}); } - if (_audioCtx.state === 'suspended') { - _audioCtx.resume(); + if (!_clickBuffer || _clickBuffer.sampleRate !== ctx.sampleRate) { + _clickBuffer = createClickBuffer(ctx); } - const t = _audioCtx.currentTime; - // Master gain for smooth, gentle volume - const master = _audioCtx.createGain(); + if (_clickBuffer) { + const source = ctx.createBufferSource(); + source.buffer = _clickBuffer; + source.connect(ctx.destination); + source.start(0); + return; + } + + // Live oscillator fallback with safe lookahead if buffer creation is unavailable + const t = Math.max(ctx.currentTime, 0) + 0.005; + const master = ctx.createGain(); master.gain.setValueAtTime(0.15, t); - master.connect(_audioCtx.destination); + master.connect(ctx.destination); - // Warm body oscillator: smooth sine wave sweeping down (soft mechanical tactile feel) - const osc = _audioCtx.createOscillator(); - const oscGain = _audioCtx.createGain(); + const osc = ctx.createOscillator(); + const oscGain = ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(420, t); osc.frequency.exponentialRampToValueAtTime(110, t + 0.024); oscGain.gain.setValueAtTime(0.0001, t); - oscGain.gain.linearRampToValueAtTime(1.0, t + 0.0015); + oscGain.gain.linearRampToValueAtTime(1.0, t + 0.002); oscGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.024); osc.connect(oscGain); oscGain.connect(master); - // Subtle transient click layer: triangle wave with gentle decay - const snap = _audioCtx.createOscillator(); - const snapGain = _audioCtx.createGain(); + const snap = ctx.createOscillator(); + const snapGain = ctx.createGain(); snap.type = 'triangle'; snap.frequency.setValueAtTime(1200, t); snap.frequency.exponentialRampToValueAtTime(320, t + 0.008); snapGain.gain.setValueAtTime(0.0001, t); - snapGain.gain.linearRampToValueAtTime(0.3, t + 0.001); + snapGain.gain.linearRampToValueAtTime(0.3, t + 0.0015); snapGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.009); snap.connect(snapGain); @@ -12506,8 +13017,8 @@ document.addEventListener('DOMContentLoaded', () => { osc.start(t); snap.start(t); - osc.stop(t + 0.03); - snap.stop(t + 0.012); + osc.stop(t + 0.035); + snap.stop(t + 0.015); } catch (e) { // AudioContext blocked or unsupported } @@ -12515,42 +13026,83 @@ document.addEventListener('DOMContentLoaded', () => { window.playSteuerungClickSound = playSteuerungClickSound; - let lastTriggerTime = 0; + let lastSoundTime = 0; + let lastPointerDownTime = 0; + + function resolveSteuerungTarget(el) { + if (!el) return null; + const steuerungEl = el.closest && el.closest('.steuerung a, .steuerung button, .steuerung [role="button"]'); + if (steuerungEl) return steuerungEl; + if (el.id === 'prev' || (el.classList && el.classList.contains('nav-prev')) || (el.closest && el.closest('.arrow-prev, .previous-post'))) { + return document.querySelector('.steuerung .nav-prev') || el; + } + if (el.id === 'next' || (el.classList && el.classList.contains('nav-next')) || (el.closest && el.closest('.arrow-next, .next-post'))) { + return document.querySelector('.steuerung .nav-next') || el; + } + if (el.id === 'random' || el.id === 'nav-random' || (el.getAttribute && el.getAttribute('href') === '/random')) { + return document.querySelector('.steuerung #random, .steuerung a[href="/random"]') || el; + } + return null; + } + function triggerSteuerungFeedback(target) { if (!target) return; - if (target.style.visibility === 'hidden' || target.getAttribute('href') === '#') return; + const steuerungTarget = resolveSteuerungTarget(target) || target; + if (!steuerungTarget) return; + if (steuerungTarget.style && steuerungTarget.style.visibility === 'hidden') return; + if (steuerungTarget.getAttribute && steuerungTarget.getAttribute('href') === '#') return; + try { + const comp = window.getComputedStyle(steuerungTarget); + if (comp.visibility === 'hidden' || comp.display === 'none') return; + } catch (e) {} - const now = Date.now(); - if (now - lastTriggerTime < 80) return; - lastTriggerTime = now; + const now = performance.now(); + // 15ms minimum gap to prevent accidental double-execution on the exact same gesture frame + if (now - lastSoundTime < 15) return; + lastSoundTime = now; + lastPointerDownTime = now; if (navigator.vibrate) { try { navigator.vibrate(25); } catch (e) {} } - target.classList.add('is-clicked'); - setTimeout(() => { - target.classList.remove('is-clicked'); - }, 120); + if (steuerungTarget.classList) { + steuerungTarget.classList.remove('is-clicked'); + void steuerungTarget.offsetWidth; // Force reflow to immediately restart CSS click animation + steuerungTarget.classList.add('is-clicked'); + clearTimeout(steuerungTarget._clickTimer); + steuerungTarget._clickTimer = setTimeout(() => { + steuerungTarget.classList.remove('is-clicked'); + }, 150); + } playSteuerungClickSound(); } - // Pointerdown gives instant tactile audio without waiting for pointerup/click - document.addEventListener('pointerdown', (e) => { - const target = e.target.closest('.steuerung a, .steuerung button, .steuerung [role="button"]'); - if (target) { - triggerSteuerungFeedback(target); - } - }, { passive: true }); + window.triggerSteuerungFeedback = triggerSteuerungFeedback; - // Keyboard navigation / programmatic .click() fallback - document.addEventListener('click', (e) => { - const target = e.target.closest('.steuerung a, .steuerung button, .steuerung [role="button"]'); + // Pointerdown fires immediately on finger/mouse press for instant 0ms latency tactile audio + document.addEventListener('pointerdown', (e) => { + if (e.button !== undefined && e.button !== 0) return; + const target = resolveSteuerungTarget(e.target); if (target) { + lastPointerDownTime = performance.now(); triggerSteuerungFeedback(target); } - }); + }, { capture: true, passive: true }); + + // Click handler serves as fallback for keyboard navigation, programmatic .click(), and touch devices + // where pointerdown didn't fire or was prevented. + document.addEventListener('click', (e) => { + if (e.button !== undefined && e.button !== 0) return; + const target = resolveSteuerungTarget(e.target); + if (target) { + const now = performance.now(); + // If pointerdown already triggered sound for this user interaction, don't duplicate on click release + if (now - lastPointerDownTime < 350) return; + triggerSteuerungFeedback(target); + } + }, { capture: true }); })(); // ── Steuerung icon style: #scrolltobottom smooth scroll ─────────────────────── @@ -13745,6 +14297,34 @@ document.addEventListener('keydown', (e) => { f0ckInterestEngine.recordInteraction({ itemId, tags, creator, type: 'click_suggestion' }); }, + recordTagSearch: (rawTag) => { + if (!rawTag || typeof rawTag !== 'string') return; + const tags = rawTag.split(',') + .map(t => t.trim().toLowerCase()) + .filter(t => t && !t.startsWith('title:') && !t.startsWith('src:')); + + if (!tags.length) return; + + // Update local storage (for immediate in-session recommendations & guest learning) + tags.forEach(t => updateLocalAffinity(STORAGE_TAGS, t, 2.0)); + + // Dispatch tracking to backend + const payload = { tag: tags.join(',') }; + try { + if (navigator.sendBeacon) { + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + navigator.sendBeacon('/api/v2/track/search', blob); + } else { + fetch('/api/v2/track/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true + }).catch(() => {}); + } + } catch (_) {} + }, + flushCurrentTracking: () => { if (!currentTrackingItem) return; const { itemId, startTime, maxPercent, tags, creator } = currentTrackingItem; diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index e76807f..6f58f04 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -97,7 +97,11 @@ let autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10)); let leftHandEnabled = prefs.leftHand === true; - let applied = { mode: defaultMode, mime: '', order: 'random', tags: [], externalUrl: null }; + const isScrollerAnon = !(window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon); + const scrollerAllowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes; + const scrollerSingleMime = (isScrollerAnon && Array.isArray(scrollerAllowedMimes) && scrollerAllowedMimes.length === 1) ? scrollerAllowedMimes[0] : null; + + let applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null }; let pending = { ...applied, tags: [] }; // Volume / mute @@ -192,7 +196,8 @@ if (hid && !cache.items.some(item => String(item.id) === hid)) return false; if (cache.filters) { - applied = { mode: defaultMode, mime: '', order: 'random', tags: [], ...cache.filters }; + applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], ...cache.filters }; + if (scrollerSingleMime) applied.mime = scrollerSingleMime; applied.tags = Array.isArray(cache.filters.tags) ? [...cache.filters.tags] : []; pending = { ...applied, tags: [...applied.tags] }; } @@ -1141,7 +1146,23 @@ } async function toggleFav(slide) { - if (!window.scrollerLoggedIn) return; + if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) { + const errMsg = 'Anonymous favoriting is disabled.'; + if (typeof window.flashMessage === 'function') { + window.flashMessage(errMsg, 3000, 'error'); + } else if (typeof showShareToast === 'function') { + showShareToast(errMsg); + } + return; + } + if (!window.scrollerLoggedIn) { + if (typeof showShareToast === 'function') { + showShareToast('Login to favorite posts'); + } else if (typeof window.flashMessage === 'function') { + window.flashMessage('Login to favorite posts', 3000, 'warning'); + } + return; + } const id = slide.dataset.localId || slide.dataset.id; // External items have non-numeric IDs (e.g. "gif/123") — can't fav until rehosted if (!/^\d+$/.test(id)) { showShareToast('Can\u2019t fav external items'); return; } @@ -1157,10 +1178,6 @@ if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (nowFaved ? 1 : -1)); if (nowFaved) flashFav(slide); } - if (!window.scrollerLoggedIn && (!window.f0ckSession || !window.f0ckSession.user)) { - showShareToast('Login to favorite posts'); - return; - } try { const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || ''; const resp = await fetch('/api/v2/togglefav', { @@ -1171,7 +1188,24 @@ }, body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}` }); - const data = await resp.json(); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + // Rollback optimistic update + if (favBtn) { + favBtn.classList.toggle('faved', wasFaved); + const icon = favBtn.querySelector('i'); + if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart'; + const countEl = favBtn.querySelector('.scroll-btn-count'); + if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1)); + } + const errMsg = (data && (data.msg || data.error)) || 'Anonymous favoriting is disabled.'; + if (typeof window.flashMessage === 'function') { + window.flashMessage(errMsg, 3000, 'error'); + } else if (typeof showShareToast === 'function') { + showShareToast(errMsg); + } + return; + } // Sync count to server truth (handles race conditions) if (data.success && favBtn && data.favs) { const countEl = favBtn.querySelector('.scroll-btn-count'); @@ -1183,6 +1217,8 @@ favBtn.classList.toggle('faved', wasFaved); const icon = favBtn.querySelector('i'); if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart'; + const countEl = favBtn.querySelector('.scroll-btn-count'); + if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1)); } } } @@ -1349,7 +1385,7 @@ const meta = document.createElement('div'); meta.className = 'scroll-meta'; // esc() the color value: a raw '"' in username_color would break out of the // style attribute and allow arbitrary HTML injection (XSS). - const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in; + const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in); const colorStyle = (!isAnonGuest && item.username_color) ? `color:${esc(item.username_color)}` : ''; const ratingHtml = `${esc(item.rating_label)}`; const ocHtml = item.is_oc ? ` OC` : ''; @@ -2176,7 +2212,7 @@ function renderCommentEl(c, canReply) { const el = document.createElement('div'); el.className = 'comment-item'; el.dataset.commentId = c.id || ''; - const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in; + const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in); const av = isAnonGuest ? '/a/default.png' : (c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png')); const nc = (!isAnonGuest && c.username_color) ? `color:${esc(c.username_color)}` : ''; const _i = window.f0ckI18n || {}; @@ -3003,6 +3039,10 @@ }); function syncPanelUI() { + if (scrollerSingleMime) { + pending.mime = scrollerSingleMime; + applied.mime = scrollerSingleMime; + } document.querySelectorAll('#mode-pills .filter-pill').forEach(p => p.classList.toggle('active', +p.dataset.mode === pending.mode)); document.querySelectorAll('#mime-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.mime === pending.mime)); document.querySelectorAll('#order-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.order === pending.order)); @@ -3011,9 +3051,15 @@ } function makePillListener(groupId, key, transform) { - document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(pill => { - pill.addEventListener('click', () => { - document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active')); + const el = document.getElementById(groupId); + if (!el) return; + el.querySelectorAll('.filter-pill').forEach(pill => { + pill.addEventListener('click', (e) => { + if (groupId === 'mime-pills' && scrollerSingleMime) { + e.preventDefault(); + return; + } + el.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active')); pill.classList.add('active'); pending[key] = transform ? transform(pill.dataset[key]) : pill.dataset[key]; }); }); @@ -3078,7 +3124,7 @@ } } - filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); }); + filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); }); function updateFilterSummary() { const is4chan = !!applied.externalUrl; @@ -3500,11 +3546,26 @@ const initScrollerSSE = () => { if (sseEs) sseEs.close(); - sseEs = new EventSource(`/api/notifications/stream?tabId=${tabId}`); + let sseUrl = `/api/notifications/stream?tabId=${tabId}`; + try { + const fp = localStorage.getItem('f0ck_anon_ssh_fp'); + if (fp) sseUrl += `&fp=${encodeURIComponent(fp)}`; + const hw = localStorage.getItem('f0ck_anon_hw_fp'); + if (hw) sseUrl += `&hw=${encodeURIComponent(hw)}`; + } catch (e) {} + sseEs = new EventSource(sseUrl); sseEs.onopen = () => { sseRetryCount = 0; }; sseEs.onmessage = (e) => { try { const data = JSON.parse(e.data); + if (data.type === 'banned') { + try { + const tombstoneData = { banned: true, reason: data.data?.reason, expires: data.data?.expires }; + document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstoneData))}; Path=/; Max-Age=31536000; SameSite=Lax`; + } catch (err) {} + window.location.href = data.data?.redirect || '/banned'; + return; + } if (data.type === 'notify') { pollNotifCount(); // instant re-fetch on SSE push if (navigator.vibrate) navigator.vibrate([200, 80, 200]); diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index a360f8b..6c765cb 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -562,7 +562,7 @@ ? `style="--author-banner: url('/a/${bannerFile}'); --author-banner-position: ${bannerPos === 'center' ? 'center top' : (bannerPos || 'center top')}; --author-banner-size: ${(bannerSz && bannerSz !== 'cover') ? bannerSz : '100% auto'}; --author-banner-repeat: no-repeat;"` : ''; - const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in; + const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in); const effectiveBannerStyle = isAnonGuest ? '' : bannerStyle; const authorAvatarHtml = isAnonGuest ? `` @@ -1031,13 +1031,31 @@ let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null; const getCurrentMimeFilter = () => { + const allowed = window.f0ckSession?.is_anon && Array.isArray(window.f0ckSession?.anon_permissions?.allowed_mimes) ? window.f0ckSession.anon_permissions.allowed_mimes : null; const urlParams = new URLSearchParams(window.location.search); - const qMime = urlParams.get('mime'); - if (qMime !== null) return qMime.trim(); + let qMime = urlParams.get('mime'); + if (qMime !== null) { + qMime = qMime.trim(); + if (allowed) { + const parts = qMime.split(',').filter(m => allowed.includes(m)); + return parts.length > 0 ? parts.join(',') : (allowed.length < 5 ? allowed.join(',') : ''); + } + return qMime; + } const cookieMime = document.cookie.split('; ').find(row => row.startsWith('mime=')); if (cookieMime) { const val = cookieMime.split('=')[1]; - if (typeof val === 'string') return decodeURIComponent(val).trim(); + if (typeof val === 'string') { + const cMime = decodeURIComponent(val).trim(); + if (allowed) { + const parts = cMime.split(',').filter(m => allowed.includes(m)); + return parts.length > 0 ? parts.join(',') : (allowed.length < 5 ? allowed.join(',') : ''); + } + return cMime; + } + } + if (allowed && allowed.length < 5) { + return allowed.join(','); } return ''; }; @@ -1176,7 +1194,7 @@ displayTitle = `${videoKey}`; } - const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in; + const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in); const authorName = isAnonGuest ? 'anonymous' : (video.display_name || video.username); const userColorStyle = (!isAnonGuest && video.username_color) ? `style="color: ${escapeHtml(video.username_color)}"` : ''; diff --git a/public/s/js/user.js b/public/s/js/user.js index 3f18549..7551093 100644 --- a/public/s/js/user.js +++ b/public/s/js/user.js @@ -95,7 +95,7 @@ if (canManage) { span.classList.add('can-cycle'); } - } else if (tag.display_name || tag.user) { + } else if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) { span.setAttribute('tooltip', tag.display_name || tag.user); } @@ -196,6 +196,13 @@ const toggleFavEvent = async (e) => { // e is the click event or undefined + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) { + if (typeof window.flashMessage === 'function') { + window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error'); + } + return; + } const ctx = getContext(); if (!ctx) return; const { postid } = ctx; @@ -204,50 +211,59 @@ const favoBtn = document.querySelector("#a_favo"); const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid'); - const res = await post('/api/v2/togglefav', { - postid: postid - }); - if (res.success) { - if (window.invalidateItemCache) { - window.invalidateItemCache(postid); + try { + const res = await post('/api/v2/togglefav', { + postid: postid + }); + if (res && res.success) { + if (window.invalidateItemCache) { + window.invalidateItemCache(postid); + } + // New state is the logical opposite of what it was before the API call + const isNowFav = !wasAlreadyFav; + + if (favoBtn) { + favoBtn.classList.toggle('fa-solid', isNowFav); + favoBtn.classList.toggle('fa-regular', !isNowFav); + } + + // span#favs + const favcontainer = document.querySelector('#favs'); + favcontainer.innerHTML = ""; + if (res.favs && res.favs.length > 0) { + res.favs.forEach(f => { + const a = document.createElement('a'); + a.href = `/user/${f.user}`; + a.setAttribute('tooltip', f.display_name || f.user); + a.setAttribute('flow', 'up'); + + const img = document.createElement('img'); + img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png'); + img.style.height = "32px"; + img.style.width = "32px"; + if (f.username_color) img.style.borderColor = f.username_color; + + a.appendChild(img); + favcontainer.appendChild(a); + }); + favcontainer.hidden = false; + } else { + favcontainer.hidden = true; + } + + window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES')); + if (navigator.vibrate) navigator.vibrate(50); } - // New state is the logical opposite of what it was before the API call - const isNowFav = !wasAlreadyFav; - - if (favoBtn) { - favoBtn.classList.toggle('fa-solid', isNowFav); - favoBtn.classList.toggle('fa-regular', !isNowFav); + else { + const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.'; + if (typeof window.flashMessage === 'function') { + window.flashMessage(errMsg, 3000, 'error'); + } } - - // span#favs - const favcontainer = document.querySelector('#favs'); - favcontainer.innerHTML = ""; - if (res.favs.length > 0) { - res.favs.forEach(f => { - const a = document.createElement('a'); - a.href = `/user/${f.user}`; - a.setAttribute('tooltip', f.display_name || f.user); - a.setAttribute('flow', 'up'); - - const img = document.createElement('img'); - img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png'); - img.style.height = "32px"; - img.style.width = "32px"; - if (f.username_color) img.style.borderColor = f.username_color; - - a.appendChild(img); - favcontainer.appendChild(a); - }); - favcontainer.hidden = false; - } else { - favcontainer.hidden = true; + } catch (err) { + if (typeof window.flashMessage === 'function') { + window.flashMessage('Failed to update favorite.', 3000, 'error'); } - - window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES')); - if (navigator.vibrate) navigator.vibrate(50); - } - else { - // lul } }; diff --git a/src/comment_upload_handler.mjs b/src/comment_upload_handler.mjs index 903d574..13ba808 100644 --- a/src/comment_upload_handler.mjs +++ b/src/comment_upload_handler.mjs @@ -5,6 +5,7 @@ import cfg from "./inc/config.mjs"; import queue from "./inc/queue.mjs"; import path from "path"; import { collectBody } from "./inc/multipart.mjs"; +import { canAnonDo, isAnonSession } from "./inc/settings.mjs"; // Helper for JSON response const sendJson = (res, data, code = 200) => { @@ -141,6 +142,12 @@ export const handleCommentUpload = async (req, res) => { return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403); } + if (isAnonSession(req.session)) { + if (!canAnonDo('comment') || !canAnonDo('comment_attachments')) { + return sendJson(res, { success: false, msg: 'Anonymous comment file uploads are disabled' }, 403); + } + } + // Check if comment file upload is enabled if (!cfg.websrv.allow_fileupload_comments) { return sendJson(res, { success: false, msg: 'Comment file uploads are disabled' }, 403); diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index 56ace0b..80444ef 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -4,7 +4,7 @@ import db from "./sql.mjs"; import cfg from "./config.mjs"; import { createI18n } from "./i18n.mjs"; -import { getEnableAnonymousAccess } from "./settings.mjs"; +import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo } from "./settings.mjs"; @@ -285,7 +285,8 @@ export default new class { return false; }; async getTags(itemid, session = null) { - const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session)); + const isAnonymized = isAnonymizeSession(session); + const hasSession = !isAnonymized && !!(session && !session.is_anon && (typeof session === 'object' ? (session.id || session.user) : session)); const tags = await db` select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``} from "tags_assign" @@ -296,6 +297,8 @@ export default new class { `; let hasRating = false; const cleanTags = []; + const excludedTagIds = (session && Array.isArray(session.excluded_tags)) ? session.excluded_tags : []; + const canExclude = (session && !session.is_anon) ? true : canAnonDo('exclude_tags'); for (let t = 0; t < tags.length; t++) { const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized); if (isRating) { @@ -303,6 +306,8 @@ export default new class { hasRating = true; } tags[t].badge = this.getBadge(tags[t]); + tags[t].is_excluded = !isRating && excludedTagIds.includes(tags[t].id); + tags[t].can_exclude = canExclude; if (!hasSession) { delete tags[t].user; delete tags[t].display_name; diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index 0a4a7cc..457027d 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -270,6 +270,12 @@ "start_export": "Export generieren (ZIP)" }, "filter": { + "excluded_tags": "Ausgeschlossene Tags", + "exclude_tag": "Tag ausschließen", + "unexclude_tag": "Ausgeschlossen (klicken zum Aufheben)", + "no_tags_excluded": "Noch keine Tags ausgeschlossen", + "tag_excluded_msg": "Tag '{tag}' zu ausgeschlossenen Tags hinzugefügt", + "tag_unexcluded_msg": "Tag '{tag}' aus ausgeschlossenen Tags entfernt", "tag_placeholder": "Tag ausschließen", "random_mode": "RAND", "min_xd_score": "Min. xD-Score", diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index 45716ac..3ff9d80 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -270,6 +270,12 @@ "start_export": "Generate Export (ZIP)" }, "filter": { + "excluded_tags": "Excluded Tags", + "exclude_tag": "Exclude tag", + "unexclude_tag": "Excluded (click to unexclude)", + "no_tags_excluded": "No tags excluded yet", + "tag_excluded_msg": "Tag '{tag}' added to excluded tags", + "tag_unexcluded_msg": "Tag '{tag}' removed from excluded tags", "tag_placeholder": "Tag to exclude", "random_mode": "RAND", "min_xd_score": "Min xD Score", diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index cf09a32..6e2a195 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -268,6 +268,12 @@ "start_export": "Export genereren (ZIP)" }, "filter": { + "excluded_tags": "Uitgesloten Tags", + "exclude_tag": "Tag uitsluiten", + "unexclude_tag": "Uitgesloten (klik om te herstellen)", + "no_tags_excluded": "Nog geen tags uitgesloten", + "tag_excluded_msg": "Tag '{tag}' toegevoegd aan uitgesloten tags", + "tag_unexcluded_msg": "Tag '{tag}' verwijderd uit uitgesloten tags", "tag_placeholder": "Tag om uit te sluiten", "random_mode": "WILLEKEURIG", "min_xd_score": "Min xD-score", diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index c63adfd..af22747 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -266,6 +266,12 @@ "start_export": "Paket schnüren" }, "filter": { + "excluded_tags": "Ausgeschlossene Etiketten", + "exclude_tag": "Etikett ausschließen", + "unexclude_tag": "Ausgeschlossen (klicken zum Wiederherstellen)", + "no_tags_excluded": "Noch keine Etiketten ausgeschlossen", + "tag_excluded_msg": "Etikett '{tag}' zu ausgeschlossenen Etiketten hinzugefügt", + "tag_unexcluded_msg": "Etikett '{tag}' aus ausgeschlossenen Etiketten entfernt", "tag_placeholder": "Auszuschließendes Etikett", "random_mode": "ZUFA", "min_xd_score": "Min. xD-Punktestand", diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 274f82e..10a315f 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -1,7 +1,7 @@ import db from "../sql.mjs"; import lib from "../lib.mjs"; import cfg from "../config.mjs"; -import { getEnableItemSlugs } from "../settings.mjs"; +import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession } from "../settings.mjs"; import { updateHallsCache } from "../halls_cache.mjs"; import queue from "../queue.mjs"; import fs from "fs"; @@ -15,7 +15,7 @@ const getGlobalfilter = () => { }; const computeBaseMode = (mode, ratings, session) => { - const effMode = Number(mode ?? 0); + let effMode = Number(mode ?? 0); const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null; // For guest sessions, sanitize ratingsArr to only allow permitted ratings @@ -28,6 +28,32 @@ const computeBaseMode = (mode, ratings, session) => { if (safeRatingsArr.length === 0) { return "1 = 0"; } + } else if (isAnonSession(session)) { + const allowedModes = getAnonAllowedModes(); + const canFilter = canAnonDo('filter'); + + if (!canFilter) { + safeRatingsArr = null; + effMode = 0; + } else if (safeRatingsArr) { + safeRatingsArr = safeRatingsArr.filter(r => allowedModes.includes(r)); + if (safeRatingsArr.length === 0) { + return "1 = 0"; + } + } + + const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; + const currentModeName = modeNames[effMode] || 'sfw'; + + if (effMode === 3) { + if (allowedModes.length < 5) { + safeRatingsArr = [...allowedModes]; + } + } else if (!allowedModes.includes(currentModeName)) { + const fallbackModeName = allowedModes[0] || 'sfw'; + const fallbackModeIdx = modeNames.indexOf(fallbackModeName); + effMode = fallbackModeIdx >= 0 ? fallbackModeIdx : 0; + } } let baseMode; @@ -67,6 +93,21 @@ const computeBaseMode = (mode, ratings, session) => { } else if (effMode === 4) { baseMode = "1 = 0"; } + } else if (isAnonSession(session)) { + const allowedModes = getAnonAllowedModes(); + const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3; + if (!allowedModes.includes('nsfl')) { + baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`; + } + if (!allowedModes.includes('nsfw')) { + baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 2)`; + } + if (!allowedModes.includes('untagged')) { + baseMode = `(${baseMode}) and exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`; + } + if (!allowedModes.includes('sfw')) { + baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`; + } } return baseMode; }; @@ -87,6 +128,36 @@ const resolveNumericItemId = async (itemIdOrSlug) => { // All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie) const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime); +const resolveMimeSQL = (rawMime, session, itemAlias = 'items') => { + let mimeParts = (rawMime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); + if (isAnonSession(session)) { + const allowedMimes = getAnonAllowedMimes(); + const canFilter = canAnonDo('filter'); + if (!canFilter || allowedMimes.length === 1) { + mimeParts = allowedMimes.length < 5 ? [...allowedMimes] : []; + } else { + if (mimeParts.length > 0) { + mimeParts = mimeParts.filter(m => allowedMimes.includes(m)); + if (mimeParts.length === 0) { + mimeParts = allowedMimes.length < 5 ? [...allowedMimes] : []; + } + } else if (allowedMimes.length < 5) { + mimeParts = [...allowedMimes]; + } + } + } + + const mimeSQL = mimeParts.length > 0 + ? db`and (${mimeParts.map(m => m === 'flash' + ? (flashMimes.length > 0 + ? (itemAlias === 'i' ? flashMimes.map(fm => db`i.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) : flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)) + : db`false`) + : (m === 'pdf' ? (itemAlias === 'i' ? db`i.mime = 'application/pdf'` : db`items.mime = 'application/pdf'`) : (itemAlias === 'i' ? db`i.mime ilike ${m + '/%'}` : db`items.mime ilike ${m + '/%'}`))).reduce((a, b) => db`${a} or ${b}`)})` + : db``; + + return { mimeParts, mimeSQL }; +}; + // ── Count cache ───────────────────────────────────────────────────────────── // The COUNT(DISTINCT items.id) in getf0cks is expensive (full filtered scan). // Cache it per unique filter combination for 90 seconds so that navigating @@ -300,16 +371,7 @@ const buildFeedFilters = async ({ if (uhData.length) userHallObj = uhData[0]; } const mime = rawMime ?? null; - - // Support multiple MIME types (comma separated) - const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); - const mimeSQL = mimeParts.length > 0 - ? db`and (${mimeParts.map(m => m === 'flash' - ? (flashMimes.length > 0 - ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) - : db`false`) - : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` - : db``; + const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session); const excludedTags = session && exclude ? (exclude || []) : []; const newerThan = newer ? parseInt(newer) : null; @@ -853,14 +915,7 @@ const f0cklib = { const isNumeric = /^\d+$/.test(String(rawIdOrSlug)); const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`; - const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); - const mimeSQL = mimeParts.length > 0 - ? db`and (${mimeParts.map(m => m === 'flash' - ? (flashMimes.length > 0 - ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) - : db`false`) - : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` - : db``; + const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session); const excludedTags = exclude || []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; @@ -1404,14 +1459,7 @@ const f0cklib = { } // Support multiple MIME types (comma separated) - const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); - const mimeSQL = mimeParts.length > 0 - ? db`and (${mimeParts.map(m => m === 'flash' - ? (flashMimes.length > 0 - ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) - : db`false`) - : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` - : db``; + const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session); const excludedTags = session && exclude ? (exclude || []) : []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; @@ -2113,14 +2161,7 @@ const f0cklib = { ? db`AND items.id != ALL(${excludeItemIds}::int[])` : db``; - const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); - const mimeSQL = mimeParts.length > 0 - ? db`and (${mimeParts.map(m => m === 'flash' - ? (flashMimes.length > 0 - ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) - : db`false`) - : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` - : db``; + const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session); let rows; if (mimeParts.length > 0) { @@ -2298,6 +2339,59 @@ const f0cklib = { } }, + updateUserTagAffinity: async ({ user_id, tag, scoreDelta = 2.0 }) => { + if (!user_id || !tag || !scoreDelta) return; + try { + const rawList = typeof tag === 'string' + ? tag.split(',') + : (Array.isArray(tag) ? tag : [tag]); + + const tagsList = rawList + .map(t => typeof t === 'string' ? t.trim().toLowerCase() : '') + .filter(t => t && !t.startsWith('title:') && !t.startsWith('src:')) + .slice(0, 10); + + if (tagsList.length === 0) return; + + const slugList = tagsList.map(t => lib.slugify(t)).filter(Boolean); + + const tagRows = await db` + SELECT DISTINCT id FROM tags + WHERE LOWER(tag) = ANY(${tagsList}::text[]) + OR (normalized != '' AND normalized = ANY(${slugList}::text[])) + `; + if (tagRows.length === 0) return; + + const tagIds = tagRows.map(r => r.id); + + // Guard: Only increment score and interaction_count if last_interacted was more than 10s ago, + // avoiding duplicate score inflation from rapid page refreshes or dual beacon/page loads. + await db` + INSERT INTO user_tag_affinity (user_id, tag_id, score, interaction_count, last_interacted) + SELECT + ${user_id}, + unnest(${tagIds}::int[]), + ${scoreDelta}, + 1, + now() + ON CONFLICT (user_id, tag_id) DO UPDATE SET + score = CASE + WHEN user_tag_affinity.last_interacted < now() - interval '10 seconds' + THEN GREATEST(-10.0, LEAST(1000.0, user_tag_affinity.score + EXCLUDED.score)) + ELSE user_tag_affinity.score + END, + interaction_count = CASE + WHEN user_tag_affinity.last_interacted < now() - interval '10 seconds' + THEN user_tag_affinity.interaction_count + 1 + ELSE user_tag_affinity.interaction_count + END, + last_interacted = now() + `; + } catch (err) { + console.error("[AFFINITY] Failed to update user tag affinity from search:", err); + } + }, + decayUserAffinities: async () => { try { await db` @@ -2455,14 +2549,7 @@ const f0cklib = { ? db`AND items.id != ALL(${excludeItemIds}::int[])` : db``; - const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); - const mimeSQL = mimeParts.length > 0 - ? db`and (${mimeParts.map(m => m === 'flash' - ? (flashMimes.length > 0 - ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) - : db`false`) - : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` - : db``; + const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session); let personalizedItems = []; if (personalizedTarget > 0 && targetTagIds.length > 0) { @@ -2671,14 +2758,7 @@ const f0cklib = { ? db`AND (COALESCE(items.visibility, 0) = 0 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))` : db`AND COALESCE(items.visibility, 0) = 0`); - const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); - const mimeSQL = mimeParts.length > 0 - ? db`and (${mimeParts.map(m => m === 'flash' - ? (flashMimes.length > 0 - ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) - : db`false`) - : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` - : db``; + const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session); const terms = tag.split(',').map(t => t.trim()).filter(Boolean); const isStrict = !!strict || (tag && tag.includes(',')); @@ -2835,6 +2915,7 @@ const f0cklib = { processEmbeds, computeXdScore, xdScoreMeta, + resolveMimeSQL, // Bust the count cache (call after a new upload is accepted so page totals stay accurate) clearCountCache: () => countCache.clear() }; diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index bd6edc5..b409421 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -77,7 +77,15 @@ export default (router, tpl) => { } else { const reason = user[0].ban_reason || 'none'; const expires = user[0].ban_expires ? new Date(user[0].ban_expires).toISOString().replace('T', ' ').substring(0, 16) : 'never'; - return fail(`You are banned! reason: ${reason} expire: ${expires}`); + if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: false, + banned: true, + msg: `You are banned! reason: ${reason} expire: ${expires}`, + redirect: '/banned' + })); + } + return res.writeHead(302, { Location: '/banned' }).end(); } } @@ -452,6 +460,14 @@ export default (router, tpl) => { await audit.log(req.session.id, 'ban_ip', 'ip', null, { ip, reason, duration }); + // Broadcast ban event via SSE + await db.notify('bans', JSON.stringify({ + ip, + ipHash, + reason: (reason || 'Banned by moderator').substring(0, 300), + expires + })).catch(() => {}); + return res.json({ success: true }); } catch (err) { return res.json({ success: false, msg: err.message }); @@ -682,6 +698,16 @@ export default (router, tpl) => { expires, banIps: true }); + } else { + // Broadcast ban to registered user's active SSE sessions + const userIps = await db`SELECT distinct ip FROM user_ips WHERE user_id = ${+user_id}`; + const ips = userIps.map(r => r.ip).filter(Boolean); + await db.notify('bans', JSON.stringify({ + userId: +user_id, + reason: (reason || 'Violation of community rules').substring(0, 300), + expires, + ips + })).catch(() => {}); } // Log it in audit @@ -1109,87 +1135,179 @@ export default (router, tpl) => { const page = Math.max(1, parseInt(req.url.qs?.page) || 1); const limit = 50; const offset = (page - 1) * limit; + const rawStatus = (req.url.qs?.status || req.url.qs?.filter || '').toLowerCase().trim(); + const rawRole = (req.url.qs?.role || '').toLowerCase().trim(); - const users = await db` - WITH filtered_users AS ( - SELECT - u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated, - uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode, - (SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method - FROM "user" u - LEFT JOIN user_options uo ON uo.user_id = u.id - ${q ? (exactMatch - ? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})` - : db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` - ) : db``} - ), - ghost_users AS ( - SELECT - NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email, - to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated, - NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method + const onlyLegacy = req.url.qs?.legacy === '1' || req.url.qs?.legacy === 'true' || + req.url.qs?.legacy_only === '1' || req.url.qs?.legacy_only === 'true' || + req.url.qs?.only_legacy === '1' || req.url.qs?.only_legacy === 'true' || + rawStatus === 'legacy'; + + const status = onlyLegacy ? '' : rawStatus; + const role = onlyLegacy ? '' : rawRole; + + let users; + let total; + + if (onlyLegacy) { + users = await db` + WITH ghost_users AS ( + SELECT + NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email, + to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated, + NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method + FROM items i + WHERE i.username IS NOT NULL AND i.username != '' + AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) + ${q ? (exactMatch + ? db`AND lower(i.username) = lower(${q})` + : db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` + ) : db``} + GROUP BY i.username + ), + paginated_users AS ( + SELECT * FROM ghost_users + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset} + ) + SELECT + pu.*, + EXTRACT(DAY FROM (now() - pu.created_at)) as age_days, + COALESCE(ic.upload_count, 0) as upload_count, + 0::bigint as comment_count, + 0::bigint as failed_attempts + FROM paginated_users pu + LEFT JOIN LATERAL ( + SELECT COUNT(*) as upload_count + FROM items + WHERE (username = pu.login OR username = pu.user) AND is_deleted = false + ) ic ON true + `; + + const totalCountGhost = await db` + SELECT COUNT(DISTINCT i.username) as c FROM items i - WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) + WHERE i.username IS NOT NULL AND i.username != '' + AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) ${q ? (exactMatch ? db`AND lower(i.username) = lower(${q})` : db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` ) : db``} - GROUP BY i.username - ), - all_users AS ( - SELECT * FROM filtered_users - UNION ALL - SELECT * FROM ghost_users - ), - paginated_users AS ( - SELECT * FROM all_users - ORDER BY created_at DESC - LIMIT ${limit} OFFSET ${offset} - ) - SELECT - pu.*, - EXTRACT(DAY FROM (now() - pu.created_at)) as age_days, - COALESCE(ic.upload_count, 0) as upload_count, - COALESCE(cc.comment_count, 0) as comment_count, - COALESCE(la.failed_attempts, 0) as failed_attempts - FROM paginated_users pu - LEFT JOIN LATERAL ( - SELECT COUNT(*) as upload_count - FROM items - WHERE (username = pu.login OR username = pu.user) AND is_deleted = false - ) ic ON true - LEFT JOIN LATERAL ( - SELECT COUNT(*) as comment_count - FROM comments - WHERE user_id = pu.id AND is_deleted = false - ) cc ON pu.id IS NOT NULL - LEFT JOIN LATERAL ( - SELECT COUNT(*) as failed_attempts - FROM login_attempts - WHERE username = pu.login - AND success = false - AND type = 'login' - AND attempted_at > now() - interval '10 hours' - ) la ON true - `; + `; + total = parseInt(totalCountGhost[0].c); + } else { + let qCond = null; + if (q) { + if (exactMatch) { + qCond = db`(lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q}))`; + } else { + const pattern = '%' + lib.escapeLike(q) + '%'; + qCond = db`(u.login ILIKE ${pattern} OR u.user ILIKE ${pattern} OR u.email ILIKE ${pattern})`; + } + } - const totalCountActual = await db` - SELECT COUNT(*) as c FROM "user" u - ${q ? (exactMatch - ? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})` - : db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` - ) : db``} - `; - const totalCountGhost = await db` - SELECT COUNT(DISTINCT i.username) as c - FROM items i - WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) - ${q ? (exactMatch - ? db`AND lower(i.username) = lower(${q})` - : db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` - ) : db``} - `; - const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c); + let statusCond = null; + if (status === 'banned') { + statusCond = db`u.banned = true`; + } else if (status === 'active') { + statusCond = db`(u.activated = true AND u.banned = false)`; + } else if (status === 'pending') { + statusCond = db`(u.activated = false AND u.banned = false)`; + } + + let roleCond = null; + if (role === 'staff' || status === 'staff') { + roleCond = db`(u.admin = true OR u.is_moderator = true)`; + } else if (role === 'admin') { + roleCond = db`u.admin = true`; + } else if (role === 'mod') { + roleCond = db`(u.is_moderator = true AND u.admin = false)`; + } else if (role === 'user') { + roleCond = db`(u.admin = false AND u.is_moderator = false)`; + } + + users = await db` + WITH filtered_users AS ( + SELECT + u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated, + uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode, + (SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method + FROM "user" u + LEFT JOIN user_options uo ON uo.user_id = u.id + WHERE true + ${qCond ? db`AND ${qCond}` : db``} + ${statusCond ? db`AND ${statusCond}` : db``} + ${roleCond ? db`AND ${roleCond}` : db``} + ), + paginated_users AS ( + SELECT * FROM filtered_users + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset} + ) + SELECT + pu.*, + EXTRACT(DAY FROM (now() - pu.created_at)) as age_days, + COALESCE(ic.upload_count, 0) as upload_count, + COALESCE(cc.comment_count, 0) as comment_count, + COALESCE(la.failed_attempts, 0) as failed_attempts + FROM paginated_users pu + LEFT JOIN LATERAL ( + SELECT COUNT(*) as upload_count + FROM items + WHERE (username = pu.login OR username = pu.user) AND is_deleted = false + ) ic ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) as comment_count + FROM comments + WHERE user_id = pu.id AND is_deleted = false + ) cc ON pu.id IS NOT NULL + LEFT JOIN LATERAL ( + SELECT COUNT(*) as failed_attempts + FROM login_attempts + WHERE username = pu.login + AND success = false + AND type = 'login' + AND attempted_at > now() - interval '10 hours' + ) la ON true + `; + + const totalCountActual = await db` + SELECT COUNT(*) as c FROM "user" u + WHERE true + ${qCond ? db`AND ${qCond}` : db``} + ${statusCond ? db`AND ${statusCond}` : db``} + ${roleCond ? db`AND ${roleCond}` : db``} + `; + total = parseInt(totalCountActual[0].c); + } + + let totalLabel = 'registered members'; + let emptyMsg = 'No users matched your search.'; + if (onlyLegacy) { + totalLabel = 'legacy accounts'; + emptyMsg = 'No legacy users matched your search.'; + } else if (status === 'banned') { + totalLabel = 'banned members'; + emptyMsg = 'No banned users found.'; + } else if (status === 'pending') { + totalLabel = 'pending members'; + emptyMsg = 'No pending users found.'; + } else if (status === 'active') { + totalLabel = 'active members'; + emptyMsg = 'No active users found.'; + } else if (role === 'staff' || status === 'staff') { + totalLabel = 'staff members'; + emptyMsg = 'No staff members found.'; + } else if (role === 'admin') { + totalLabel = 'admin members'; + emptyMsg = 'No admin users found.'; + } else if (role === 'mod') { + totalLabel = 'moderator members'; + emptyMsg = 'No moderator users found.'; + } else if (role === 'user') { + totalLabel = 'regular users'; + emptyMsg = 'No regular users found.'; + } const data = { session: req.session, @@ -1198,6 +1316,11 @@ export default (router, tpl) => { page, total, hasMore: users.length === limit, + onlyLegacy, + status, + role, + totalLabel, + emptyMsg, totals: await lib.countf0cks(), log_user_ips: getLogUserIps(), tmp: null @@ -1205,6 +1328,8 @@ export default (router, tpl) => { if (req.headers['x-requested-with'] === 'XMLHttpRequest') { res.setHeader('X-Total-Count', total.toString()); + res.setHeader('X-Total-Label', totalLabel); + res.setHeader('X-Empty-Msg', emptyMsg); res.setHeader('X-Has-More', (users.length === limit).toString()); return res.reply({ body: tpl.render("admin/users_list", data, req) diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 1558c48..a0ee773 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -2,6 +2,7 @@ import f0cklib from "../routeinc/f0cklib.mjs"; import url from "url"; import cfg from "../config.mjs"; import { createI18n } from "../i18n.mjs"; +import { isAnonymizeSession } from "../settings.mjs"; export default (router, tpl) => { router.get(/^\/ajax\/item\/(?[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => { @@ -140,8 +141,8 @@ export default (router, tpl) => { if (data.item) { const session = data.session; const item = data.item; - // When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL - if (cfg.main.guest_anonymize && !req.session) { + // When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL + if (isAnonymizeSession(req.session)) { if (item.src) item.src = null; item.username = 'anonymous'; item.author_banner_file = null; diff --git a/src/inc/routes/apiv2/anon.mjs b/src/inc/routes/apiv2/anon.mjs index 2f6ac7f..b208240 100644 --- a/src/inc/routes/apiv2/anon.mjs +++ b/src/inc/routes/apiv2/anon.mjs @@ -12,6 +12,24 @@ export default router => { * POST /api/v2/anon/session * Authenticate via OpenSSH Ed25519 signature and establish an anonymous session. */ + const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => { + if (!sourceReason) return prefix; + let clean = sourceReason; + while (/^Cascade ban from (device|hardware ID|key) \((.*)\)$/.test(clean)) { + clean = clean.replace(/^Cascade ban from (device|hardware ID|key) \((.*)\)$/, '$2'); + } + return `${prefix} (${clean || 'Violation of community rules'})`; + }; + + const setBanCookie = (res, reason, expires) => { + const payload = encodeURIComponent(JSON.stringify({ + banned: true, + reason: reason || 'Banned', + expires: expires ? new Date(expires).toISOString() : null + })); + res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`); + }; + group.post(/\/session$/, async (req, res) => { try { if (!getEnableAnonymousAccess()) { @@ -21,6 +39,7 @@ export default router => { const clientIp = security.getRealIP(req); const ipBan = await security.isIpBanned(clientIp); if (ipBan) { + setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires); return res.json({ success: false, banned: true, @@ -64,15 +83,19 @@ export default router => { const activeTombstoneBan = tombstoneBan || tombstoneHwBan; if (activeTombstoneBan) { - await security.banAnonymousUser({ - fingerprint: parsed.fingerprint, - hwFingerprint: hwFingerprint || tombstoneHw, - bannedBy: activeTombstoneBan.banned_by, - reason: `Cascade ban from device (${activeTombstoneBan.reason || 'Banned'})`, - expires: activeTombstoneBan.expires, - banIps: true, - banHardware: true - }); + const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint); + if (!alreadyFpBanned) { + await security.banAnonymousUser({ + fingerprint: parsed.fingerprint, + hwFingerprint: hwFingerprint || tombstoneHw, + bannedBy: activeTombstoneBan.banned_by, + reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'), + expires: activeTombstoneBan.expires, + banIps: true, + banHardware: true + }); + } + setBanCookie(res, activeTombstoneBan.reason || 'Device is banned', activeTombstoneBan.expires); return res.json({ success: false, banned: true, @@ -90,15 +113,19 @@ export default router => { if (hwFingerprint) { const hwBan = await security.isHardwareBanned(hwFingerprint); if (hwBan) { - await security.banAnonymousUser({ - fingerprint: parsed.fingerprint, - hwFingerprint, - bannedBy: hwBan.banned_by, - reason: `Cascade ban from hardware ID (${hwBan.reason || 'Banned'})`, - expires: hwBan.expires, - banIps: true, - banHardware: true - }); + const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint); + if (!alreadyFpBanned) { + await security.banAnonymousUser({ + fingerprint: parsed.fingerprint, + hwFingerprint, + bannedBy: hwBan.banned_by, + reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'), + expires: hwBan.expires, + banIps: true, + banHardware: true + }); + } + setBanCookie(res, hwBan.reason || 'Hardware ID is banned', hwBan.expires); return res.json({ success: false, banned: true, @@ -116,16 +143,20 @@ export default router => { const fpBan = await security.isFingerprintBanned(parsed.fingerprint); if (fpBan) { if (hwFingerprint) { - await security.banAnonymousUser({ - fingerprint: parsed.fingerprint, - hwFingerprint, - bannedBy: fpBan.banned_by, - reason: `Cascade ban from key (${fpBan.reason || 'Banned'})`, - expires: fpBan.expires, - banIps: true, - banHardware: true - }); + const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint); + if (!alreadyHwBanned) { + await security.banAnonymousUser({ + fingerprint: parsed.fingerprint, + hwFingerprint, + bannedBy: fpBan.banned_by, + reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'), + expires: fpBan.expires, + banIps: true, + banHardware: true + }); + } } + setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires); return res.json({ success: false, banned: true, @@ -144,15 +175,19 @@ export default router => { const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`; if (userRows.length > 0 && userRows[0].banned) { const u = userRows[0]; - await security.banAnonymousUser({ - userId, - fingerprint: parsed.fingerprint, - hwFingerprint, - reason: u.ban_reason || 'Banned', - expires: u.ban_expires, - banIps: true, - banHardware: true - }); + const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint); + if (!alreadyFpBanned) { + await security.banAnonymousUser({ + userId, + fingerprint: parsed.fingerprint, + hwFingerprint, + reason: u.ban_reason || 'Banned', + expires: u.ban_expires, + banIps: true, + banHardware: true + }); + } + setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires); return res.json({ success: false, banned: true, diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 7ab53aa..c713065 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -2,7 +2,7 @@ import { promises as fs } from "fs"; import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; -import { getEnableItemSlugs } from '../../settings.mjs'; +import { getEnableItemSlugs, canAnonDo, isAnonSession, isAnonymizeSession } from '../../settings.mjs'; import queue from '../../queue.mjs'; import search from '../../routeinc/search.mjs'; import path from "path"; @@ -583,7 +583,8 @@ export default router => { const userHall = req.url.qs.userHall || null; const userHallOwner= req.url.qs.userHallOwner|| null; const user = req.url.qs.user || null; - const mime = req.url.qs.mime || null; + const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null; + const mime = (typeof req.url.qs.mime !== 'undefined') ? (req.url.qs.mime || null) : (cookieMime || null); const isFav = req.url.qs.fav === 'true'; const isStrict = req.url.qs.strict === '1'; const mode = req.mode ?? 0; @@ -598,7 +599,7 @@ export default router => { mode, ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, strict: isStrict, - session: !!req.session, + session: req.session, exclude: req.session?.excluded_tags || [], user_id: req.session?.id, is_admin: req.session?.admin @@ -616,7 +617,10 @@ export default router => { const user = req.url.qs.user || pathUser || null; const pathMime = allowedMimes.includes(pathParts[4]) ? pathParts[4] : ""; - const mime = req.url.qs.mime || pathMime || (req.cookies.mime || null); + const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null; + const mime = (typeof req.url.qs?.mime !== 'undefined') + ? (req.url.qs.mime || null) + : (cookieMime || (pathMime || null)); const tag = req.url.qs.tag || null; const hall = req.url.qs.hall || null; @@ -639,7 +643,7 @@ export default router => { mode, ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, strict: isStrict, - session: !!req.session, + session: req.session, exclude: req.session?.excluded_tags || [], user_id: req.session?.id, is_admin: req.session?.admin @@ -667,7 +671,7 @@ export default router => { mode, ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, strict: isStrict, - session: !!req.session, + session: req.session, exclude: req.session?.excluded_tags || [], user_id: req.session?.id, is_admin: req.session?.admin @@ -743,7 +747,7 @@ export default router => { limit, mode, ratings: ratingsArr, - session: !!req.session, + session: req.session, exclude: req.session?.excluded_tags || [], user_id: req.session?.id, is_admin: req.session?.admin, @@ -755,6 +759,7 @@ export default router => { prefer_personalized: preferPersonalized }); + const isAnonUser = isAnonymizeSession(req.session); res.json({ success: true, items: items.map(item => ({ @@ -764,9 +769,9 @@ export default router => { dest: item.dest, mime: item.mime, stamp: item.stamp, - username: item.username, - display_name: item.display_name, - username_color: item.username_color, + username: isAnonUser ? 'anonymous' : item.username, + display_name: isAnonUser ? null : item.display_name, + username_color: isAnonUser ? null : item.username_color, has_coverart: item.has_coverart, rating_class: item.rating_class, xd_score: item.xd_score, @@ -833,6 +838,37 @@ export default router => { } }); + // Track tag search interest signal + group.post(/\/track\/search$/, async (req, res) => { + try { + let payload = req.post || {}; + if (!payload || Object.keys(payload).length === 0) { + try { + const body = await collectBody(req); + if (body && body.length > 0) payload = JSON.parse(body.toString()); + } catch (_) {} + } + + const tag = (payload.tag || payload.q || req.url.qs?.tag || req.url.qs?.q); + if (!tag || typeof tag !== 'string' || !tag.trim()) { + return res.json({ success: false, error: "Invalid tag parameter" }, 400); + } + + if (req.session?.id) { + f0cklib.updateUserTagAffinity({ + user_id: req.session.id, + tag: tag.trim(), + scoreDelta: 2.0 + }).catch(err => console.error("[TRACK] Search affinity update failed:", err)); + } + + return res.json({ success: true }); + } catch (err) { + console.error("[TRACK] Search tracking error:", err); + return res.json({ success: false, error: "Tracking failed" }, 500); + } + }); + group.get(/\/tag-feed$/, async (req, res) => { try { const tag = req.url.qs?.tag ? String(req.url.qs.tag).trim() : null; @@ -866,7 +902,7 @@ export default router => { focus_id: focusId, mode, ratings: ratingsArr, - session: !!req.session, + session: req.session, exclude: req.session?.excluded_tags || [], user_id: req.session?.id, is_admin: req.session?.admin, @@ -875,6 +911,17 @@ export default router => { }); const effectiveOffset = result.offset ?? offset; + const isAnonUser = isAnonymizeSession(req.session); + const outItems = isAnonUser + ? result.items.map(it => ({ + ...it, + username: 'anonymous', + display_name: 'anonymous', + username_color: null, + avatar: '/a/default.png', + avatar_file: null + })) + : result.items; return res.json({ success: true, @@ -883,7 +930,7 @@ export default router => { total: result.total, offset: effectiveOffset, limit: result.limit ?? limit, - items: result.items, + items: outItems, hasMore: (effectiveOffset + result.items.length) < result.total }); } catch (err) { @@ -899,6 +946,14 @@ export default router => { group.get(/\/orakel\/user$/, async (req, res) => { try { + if (isAnonymizeSession(req.session)) { + return res.json({ + success: true, + username: 'anonymous', + display_name: 'Anonymous', + id: 0 + }); + } const now = ~~(Date.now() / 1000); const sevenDaysAgo = now - 604800; // 7 days in seconds @@ -1088,6 +1143,13 @@ export default router => { prev: prev[0]?.id ?? null }; + if (isAnonymizeSession(req.session)) { + rows.username = 'anonymous'; + rows.display_name = null; + rows.user = 'anonymous'; + rows.src = null; + } + return res.json({ success: true, rows @@ -1095,6 +1157,9 @@ export default router => { }); group.get(/\/user\/(?[^\/]+)(\/(?\d+))?$/, async (req, res) => { + if (isAnonymizeSession(req.session)) { + return res.json({ success: false, msg: 'access denied' }); + } const user = req.params.user; const eps = +req.params.eps || 50; @@ -1147,6 +1212,9 @@ export default router => { group.get(/\/users\/suggest$/, async (req, res) => { + if (isAnonymizeSession(req.session)) { + return res.json({ success: true, suggestions: [] }); + } const searchString = req.url.qs.q; if (!searchString || searchString.length < 1) { return res.json({ success: false, suggestions: [] }); @@ -1357,6 +1425,9 @@ export default router => { }); group.post(/\/togglefav$/, lib.loggedin, async (req, res) => { + if (isAnonSession(req.session) && !canAnonDo('favorite')) { + return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403); + } const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid; if (rawPostid === undefined || rawPostid === null) { return res.json({ success: false, msg: 'Missing postid' }, 400); @@ -1431,6 +1502,9 @@ export default router => { }); group.post(/\/favorites\/import$/, lib.loggedin, async (req, res) => { + if (isAnonSession(req.session) && !canAnonDo('favorite')) { + return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403); + } try { const rawIds = req.post?.ids ?? req.body?.ids; let ids = []; @@ -1704,7 +1778,20 @@ export default router => { }); }); - group.post(/\/item\/(?[0-9]+)\/rating$/, lib.registeredUser, async (req, res) => { + const ratingAuth = (req, res, next) => { + if (!req.session) { + return res.json({ success: false, msg: 'Unauthorized' }, 401); + } + if (isAnonSession(req.session)) { + if (!canAnonDo('rate_item')) { + return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403); + } + return next(); + } + return lib.registeredUser(req, res, next); + }; + + group.post(/\/item\/(?[0-9]+)\/rating$/, ratingAuth, async (req, res) => { const itemid = +req.params.id; if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400); diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index d600255..bf71155 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -4,6 +4,7 @@ import cfg from '../../config.mjs'; import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; +import { canAnonDo, isAnonSession } from '../../settings.mjs'; // Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs // These routes remain for other settings API endpoints @@ -74,6 +75,9 @@ export default router => { }); group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => { + if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) { + return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403); + } const tags = await db` select t.id, t.tag, t.normalized from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id) @@ -83,10 +87,16 @@ export default router => { }); group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => { + if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) { + return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403); + } const tagname = req.post?.tagname || req.body?.tagname; - if (!tagname) return res.json({ success: false, msg: 'No tag provided' }, 400); + const tagId = req.post?.tag_id || req.body?.tag_id; + if (!tagname && !tagId) return res.json({ success: false, msg: 'No tag provided' }, 400); - const tag = (await db`select id, tag, normalized from tags where normalized = slugify(${tagname})`)[0]; + const tag = tagId + ? (await db`select id, tag, normalized from tags where id = ${+tagId}`)[0] + : (await db`select id, tag, normalized from tags where normalized = slugify(${tagname}) or tag = ${tagname}`)[0]; if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404); @@ -110,12 +120,18 @@ export default router => { join tags t on t.id = et.id `; - return res.json({ success: true, tags }, 200); + return res.json({ success: true, tags, tag }, 200); }); group.delete(/\/excluded_tags\/(?.+)/, lib.loggedin, async (req, res) => { - const tagname = decodeURIComponent(req.params.tag); - const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0]; + if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) { + return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403); + } + const tagParam = decodeURIComponent(req.params.tag); + const isNum = /^\d+$/.test(tagParam); + const tag = isNum + ? (await db`select id, tag, normalized from tags where id = ${+tagParam}`)[0] + : (await db`select id, tag, normalized from tags where normalized = slugify(${tagParam}) or tag = ${tagParam}`)[0]; if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404); @@ -135,7 +151,7 @@ export default router => { join tags t on t.id = et.id `; - return res.json({ success: true, tags }, 200); + return res.json({ success: true, tags, tag }, 200); }); // Generic Token Generation (default type=discord if not specified, though frontend should specify) diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs index 24ee81b..762ee5d 100644 --- a/src/inc/routes/apiv2/tags.mjs +++ b/src/inc/routes/apiv2/tags.mjs @@ -6,6 +6,7 @@ import cfg from "../../config.mjs"; import fs from "fs"; import path from "path"; import { logAnonActivity } from "../../anon_auth.mjs"; +import { canAnonDo, isAnonSession } from "../../settings.mjs"; export default router => { router.group(/^\/api\/v2\/tags\/(?\d+)/, group => { @@ -26,6 +27,9 @@ export default router => { group.post(/$/, lib.loggedin, async (req, res) => { // assign and/or create tag + if (isAnonSession(req.session) && !canAnonDo('tag')) { + return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403); + } const rawTagname = req.post?.tagname || req.body?.tagname; if (!req.params.postid || !rawTagname) { return res.json({ @@ -106,6 +110,9 @@ export default router => { }); group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => { + if (isAnonSession(req.session) && !canAnonDo('rate_item')) { + return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403); + } if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' }); const postid = +req.params.postid; diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 663fc73..a3a2d80 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -3,7 +3,7 @@ import { spawn as _spawnRaw } from 'child_process'; import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; -import { getEnableItemSlugs } from '../../settings.mjs'; +import { getEnableItemSlugs, canAnonDo, isAnonSession } from '../../settings.mjs'; import { applyWordFilter } from '../../wordfilter.mjs'; import queue from '../../queue.mjs'; import path from "path"; @@ -232,9 +232,21 @@ const collectBody = (req) => { export default router => { router.group(/^\/api\/v2/, group => { + const uploadApiAuth = (req, res, next) => { + if (!req.session) { + return res.json({ success: false, msg: 'Unauthorized' }, 401); + } + if (isAnonSession(req.session)) { + if (!canAnonDo('upload')) { + return res.json({ success: false, msg: 'Action requires a registered account or anonymous upload permission' }, 403); + } + return next(); + } + return lib.registeredUser(req, res, next); + }; // ── GET /api/v2/upload-url/progress/:jobId ────────────────────────────── - group.get(/\/upload-url\/progress\/(?[a-zA-Z0-9_-]+)$/, lib.registeredUser, (req, res) => { + group.get(/\/upload-url\/progress\/(?[a-zA-Z0-9_-]+)$/, uploadApiAuth, (req, res) => { const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop(); const state = progressMap.get(jobId); res.setHeader?.('Cache-Control', 'no-store'); @@ -307,7 +319,7 @@ export default router => { return [...new Set(tags)]; }; - group.get(/\/meta\/extract-url$/, lib.registeredUser, async (req, res) => { + group.get(/\/meta\/extract-url$/, uploadApiAuth, async (req, res) => { const url = req.url.qs?.url; if (!url) return res.json({ success: false, msg: 'URL required' }, 400); @@ -358,7 +370,7 @@ export default router => { } }); - group.post(/\/upload-url$/, lib.registeredUser, async (req, res) => { + group.post(/\/upload-url$/, uploadApiAuth, async (req, res) => { try { if (!cfg.websrv.web_url_upload) { return res.json({ success: false, msg: 'URL uploads are disabled' }, 403); diff --git a/src/inc/routes/banned.mjs b/src/inc/routes/banned.mjs index ac0fb6b..748d5b4 100644 --- a/src/inc/routes/banned.mjs +++ b/src/inc/routes/banned.mjs @@ -31,10 +31,15 @@ export default (router, tpl) => { } } - if (!isBanned) { - return res.writeHead(302, { - "Location": "/" - }).end(); + if (req.cookies && req.cookies.f0ck_banned) { + try { + const bData = JSON.parse(decodeURIComponent(req.cookies.f0ck_banned)); + if (bData && (bData.reason || bData.banned)) { + isBanned = true; + reason = bData.reason || reason; + expires = bData.expires || expires; + } + } catch (e) {} } res.reply({ @@ -42,8 +47,11 @@ export default (router, tpl) => { session: req.session, reason: reason, expires: expires ? new Date(expires).toLocaleString() : 'Permanent', - ban_video: cfg.websrv.ban_video, - hideNavbar: true + isBanned: isBanned, + clientIp: clientIp, + page_meta: { + title: isBanned ? 'Banned' : 'Ban Status' + } }, req) }); }); diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index 9286442..aa31e7e 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -8,7 +8,7 @@ import { promises as fs } from "fs"; import { applyWordFilter } from "../wordfilter.mjs"; import path from "path"; import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs"; -import { getEnableAnonymousAccess } from "../settings.mjs"; +import { getEnableAnonymousAccess, canAnonDo, isAnonSession, isAnonymizeSession } from "../settings.mjs"; export default (router, tpl) => { @@ -65,7 +65,7 @@ export default (router, tpl) => { } // Transform for frontend if needed, or send as is - const anonymize = !req.session && cfg.main.guest_anonymize; + const anonymize = isAnonymizeSession(req.session); const outComments = anonymize ? comments.map(c => ({ ...c, @@ -135,8 +135,8 @@ export default (router, tpl) => { // Browse User Comments router.get(/\/user\/(?[^\/]+)\/comments/, async (req, res) => { - if (cfg.main.guest_anonymize && !req.session) { - return res.redirect('/login'); + if (isAnonymizeSession(req.session)) { + return req.session ? res.redirect('/') : res.redirect('/login'); } const user = decodeURIComponent(req.params.user); @@ -413,6 +413,10 @@ export default (router, tpl) => { } if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) }); + if (isAnonSession(req.session) && !canAnonDo('comment')) { + return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Anonymous commenting is disabled" }) }); + } + // Rate limit regular users (admins and mods are exempt) if (!req.session.admin && !req.session.is_moderator) { if (isCommentRateLimited(req.session.id)) { @@ -1091,6 +1095,8 @@ export default (router, tpl) => { ? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})` : db`AND COALESCE(i.visibility, 0) = 0`); + const { mimeSQL: activityMimeSQL } = f0cklib.resolveMimeSQL(req.url.qs?.mime || (req.cookies?.mime || null), req.session, 'i'); + const comments = await db` SELECT c.*, @@ -1117,6 +1123,7 @@ export default (router, tpl) => { WHERE c.is_deleted = false AND i.active = true AND i.is_deleted = false + ${activityMimeSQL} ${visibilityFilter} AND ${db.unsafe(modequery)} ${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``} @@ -1195,7 +1202,7 @@ export default (router, tpl) => { } } - const isAnonymized = !req.session && cfg.main.guest_anonymize; + const isAnonymized = isAnonymizeSession(req.session); const processedComments = comments.map(c => { let ratingLabel = '?'; let ratingClass = 'untagged'; @@ -1431,6 +1438,9 @@ export default (router, tpl) => { // POST /api/polls/:pollId/vote — cast or change vote router.post(/\/api\/polls\/(?\d+)\/vote/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); + if (isAnonSession(req.session) && !canAnonDo('poll_vote')) { + return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Anonymous poll voting is disabled' }) }); + } if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) }); const pollId = req.params.pollId; diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index c865328..0009b55 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -4,6 +4,7 @@ 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 } from "../settings.mjs"; const auth = async (req, res, next) => { if (!req.session) @@ -13,9 +14,9 @@ const auth = async (req, res, next) => { export default (router, tpl) => { router.get(/\/user\/(?[^/]+)\/?$/, async (req, res) => { - // When guest anonymization is active, user profiles must not be accessible without a session - if (cfg.main.guest_anonymize && !req.session) { - return res.redirect('/login'); + // When anonymization is active, user profiles must not be accessible without an authenticated regular session + if (isAnonymizeSession(req.session)) { + return req.session ? res.redirect('/') : res.redirect('/login'); } const user = decodeURIComponent(req.params.user); const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null); @@ -228,17 +229,24 @@ export default (router, tpl) => { return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) }); } - // When guest anonymization is active, user gallery pages must require authentication - if (cfg.main.guest_anonymize && !req.session && req.params.user && req.params.mode) { - return res.redirect('/login'); - } - // Redirect anonymous users requesting /user/anonymous/favs to their personal shadow username favs if (req.params.mode === 'favs' && req.params.user?.toLowerCase() === 'anonymous' && req.session?.is_anon && req.session?.login) { res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` }); return res.end(); } + // When anonymization is active, user gallery pages must require authentication and hide other users + if (isAnonymizeSession(req.session) && req.params.user && req.params.mode) { + const targetUser = decodeURIComponent(req.params.user).toLowerCase(); + const isSelf = req.session?.user && ( + targetUser === req.session.user.toLowerCase() || + (req.session.login && targetUser === req.session.login.toLowerCase()) + ); + if (!isSelf) { + return req.session ? res.redirect('/') : res.redirect('/login'); + } + } + // Auto-persist strict mode from URL to session if it's there if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) { req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1'); @@ -247,6 +255,11 @@ export default (router, tpl) => { // Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag; + // Track tag browsing interest in user affinity profile + if (reqTag && req.session?.id && !reqTag.startsWith('title:')) { + f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag: reqTag, scoreDelta: 2.0 }).catch(() => {}); + } + const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({ user: req.params.user, tag: reqTag, @@ -405,8 +418,8 @@ export default (router, tpl) => { // Hall columns for display data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : ''; - // When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL - if (cfg.main.guest_anonymize && !req.session) { + // When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL + if (isAnonymizeSession(req.session)) { if (item.src) item.src = null; item.username = 'anonymous'; item.author_banner_file = null; @@ -632,6 +645,19 @@ export default (router, tpl) => { const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/); const mode = modeMatch ? +modeMatch[1] : 0; + if (isAnonSession(req.session)) { + if (!canAnonDo('filter') || !canAnonMode(mode)) { + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ + code: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: false, msg: 'Mode not permitted for anonymous users' }) + }); + } + return res.redirect('/'); + } + } + if (cfg.allowedModes[mode]) { if (req.session) { req.session.mode = mode; diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs index a9f59db..7edac16 100644 --- a/src/inc/routes/notifications.mjs +++ b/src/inc/routes/notifications.mjs @@ -3,10 +3,65 @@ import f0cklib from "../routeinc/f0cklib.mjs"; import cfg from "../config.mjs"; import { getEnableItemSlugs } from "../settings.mjs"; import { setMotd } from "../motd.mjs"; +import security from "../security.mjs"; export const clients = new Set(); const activeTabs = new Map(); // sessionId -> tabId +export function broadcastBan(data) { + if (!data) return; + const targetUserIds = new Set(); + if (data.userId) targetUserIds.add(+data.userId); + if (Array.isArray(data.userIds)) data.userIds.forEach(id => targetUserIds.add(+id)); + + const targetFps = new Set(); + if (data.fingerprint) targetFps.add(data.fingerprint); + if (Array.isArray(data.fingerprints)) data.fingerprints.forEach(fp => targetFps.add(fp)); + + const targetHws = new Set(); + if (data.hwFingerprint) targetHws.add(data.hwFingerprint); + if (Array.isArray(data.hwFingerprints)) data.hwFingerprints.forEach(hw => targetHws.add(hw)); + + const targetIps = new Set(); + if (data.ip) targetIps.add(data.ip); + if (Array.isArray(data.ips)) data.ips.forEach(ip => targetIps.add(ip)); + + const targetIpHashes = new Set(); + if (data.ipHash) targetIpHashes.add(data.ipHash); + if (Array.isArray(data.ipHashes)) data.ipHashes.forEach(h => targetIpHashes.add(h)); + + for (const client of clients) { + let isMatch = false; + + if (client.userId && targetUserIds.has(+client.userId)) { + isMatch = true; + } else if (client.fingerprint && targetFps.has(client.fingerprint)) { + isMatch = true; + } else if (client.hwFingerprint && targetHws.has(client.hwFingerprint)) { + isMatch = true; + } else if (client.ip && targetIps.has(client.ip)) { + isMatch = true; + } else if (client.ipHash && (targetIpHashes.has(client.ipHash) || targetIps.has(client.ipHash))) { + isMatch = true; + } + + if (isMatch) { + console.log(`[SSE] Delivering instant ban to client (userId: ${client.userId}, ip: ${client.ip}, tab: ${client.tabId})`); + client.send({ + type: 'banned', + data: { + reason: data.reason || 'Violation of community rules', + expires: data.expires ? (isNaN(new Date(data.expires).getTime()) ? data.expires : new Date(data.expires).toLocaleString()) : 'Permanent', + redirect: '/banned' + } + }); + setTimeout(() => { + client.close(); + }, 1000); + } + } +} + // Broadcast the deduplicated online-user list to all connected clients function broadcastChatPresence() { const seen = new Set(); @@ -83,6 +138,17 @@ db.listen('warnings', (payload) => { } }).catch(err => console.error('DB Listen Warning error:', err)); +// Global listener for bans +db.listen('bans', (payload) => { + try { + const data = JSON.parse(payload); + console.log(`[SSE] Received ban event via database notify:`, data); + broadcastBan(data); + } catch (e) { + console.error('[SSE] Ban broadcast error:', e); + } +}).catch(err => console.error('[SSE] DB Listen Ban error:', err)); + // Global listener for profile updates (display name changes etc.) db.listen('profile_update', (payload) => { try { @@ -603,8 +669,16 @@ export default (router, tpl) => { res.writeHead(200, headers); res.write(': ok\n\n'); // Warmup + const clientIp = security.getRealIP(req); + const clientIpHash = security.hashIP(clientIp); + const clientFp = req.session?.fingerprint || req.session?.anon_fingerprint || req.url.qs?.fp || null; + const clientHw = req.session?.hw_fingerprint || req.url.qs?.hw || null; + const clientUserId = (req.session && typeof req.session === 'object') ? req.session.id : null; + const client = { - userId: (req.session && typeof req.session === 'object') ? req.session.id : null, + userId: clientUserId, + fingerprint: clientFp, + hwFingerprint: clientHw, username: req.session?.user || null, display_name: req.session?.display_name || null, avatar_file: req.session?.avatar_file || null, @@ -617,7 +691,8 @@ export default (router, tpl) => { do_not_disturb: req.session?.do_not_disturb === true, sessionId, tabId, - ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, + ip: clientIp, + ipHash: clientIpHash, send: (data) => { try { res.write(`data: ${JSON.stringify(data)}\n\n`); @@ -632,6 +707,62 @@ export default (router, tpl) => { } }; + // Check if connecting client is already banned + (async () => { + let isBanned = false; + let banReason = 'Violation of community rules'; + let banExpires = null; + + if (client.userId) { + const u = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${client.userId} LIMIT 1`; + if (u[0]?.banned) { + isBanned = true; + banReason = u[0].ban_reason || banReason; + banExpires = u[0].ban_expires; + } + } + + if (!isBanned && client.ip) { + const ipBan = await security.isIpBanned(client.ip); + if (ipBan) { + isBanned = true; + banReason = ipBan.reason || banReason; + banExpires = ipBan.expires; + } + } + + if (!isBanned && client.fingerprint) { + const fpBan = await security.isFingerprintBanned(client.fingerprint); + if (fpBan) { + isBanned = true; + banReason = fpBan.reason || banReason; + banExpires = fpBan.expires; + } + } + + if (!isBanned && client.hwFingerprint) { + const hwBan = await security.isHardwareBanned(client.hwFingerprint); + if (hwBan) { + isBanned = true; + banReason = hwBan.reason || banReason; + banExpires = hwBan.expires; + } + } + + if (isBanned) { + console.log(`[SSE] Connecting client is already banned, pushing instant redirect to /banned`); + client.send({ + type: 'banned', + data: { + reason: banReason, + expires: banExpires ? (isNaN(new Date(banExpires).getTime()) ? banExpires : new Date(banExpires).toLocaleString()) : 'Permanent', + redirect: '/banned' + } + }); + setTimeout(() => client.close(), 1000); + } + })().catch(err => console.error('[SSE] Initial ban check error:', err)); + // Send any unacknowledged warnings on connection if (!isGuest && req.session?.id) { db` diff --git a/src/inc/routes/random.mjs b/src/inc/routes/random.mjs index 9d82550..b0a5713 100644 --- a/src/inc/routes/random.mjs +++ b/src/inc/routes/random.mjs @@ -43,17 +43,21 @@ export default (router, tpl) => { const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr); + const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null; + const reqQueryMime = req.url?.searchParams?.get('mime') || req.url?.qs?.mime; + const effectiveMime = (reqQueryMime !== undefined && reqQueryMime !== null) ? reqQueryMime : (cookieMime || opts.mime || null); + const data = await f0cklib.getRandom({ user: opts.user, tag: opts.tag, hall: opts.hall, - mime: opts.mime || (req.cookies.mime || null), + mime: effectiveMime, page: opts.page, fav: opts.mode === 'favs', mode: req.mode, ratings: ratingsArr, strict: opts.strict, - session: !!req.session + session: req.session }); console.log("data", data); diff --git a/src/inc/routes/scroller.mjs b/src/inc/routes/scroller.mjs index e19f1e7..a3c3bde 100644 --- a/src/inc/routes/scroller.mjs +++ b/src/inc/routes/scroller.mjs @@ -2,6 +2,7 @@ import cfg from "../config.mjs"; import db from "../sql.mjs"; import lib from "../lib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; +import { isAnonymizeSession } from "../settings.mjs"; export default (router, tpl) => { // Serve the scroller page @@ -377,7 +378,7 @@ export default (router, tpl) => { const lastItem = items[items.length - 1]; const nextCursor = lastItem ? lastItem.id : null; - const isAnonymized = !req.session && cfg.main.guest_anonymize; + const isAnonymized = isAnonymizeSession(req.session); const outItems = isAnonymized ? items.map(item => ({ ...item, diff --git a/src/inc/routes/search.mjs b/src/inc/routes/search.mjs index 44ad32a..6026adf 100644 --- a/src/inc/routes/search.mjs +++ b/src/inc/routes/search.mjs @@ -1,6 +1,7 @@ import db from "../sql.mjs"; import lib from "../lib.mjs"; import search from "../routeinc/search.mjs"; +import f0cklib from "../routeinc/f0cklib.mjs"; const _eps = 20; @@ -14,6 +15,9 @@ export default (router, tpl) => { let pagination, link; if (tag.length > 0) { + if (req.session?.id && typeof tag === 'string' && !tag.startsWith('src:') && !tag.startsWith('title:')) { + f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag, scoreDelta: 2.0 }).catch(() => {}); + } if (tag.startsWith('src:')) { total = (await db` select count(*) as total diff --git a/src/inc/routes/upload.mjs b/src/inc/routes/upload.mjs index 913f291..7e62304 100644 --- a/src/inc/routes/upload.mjs +++ b/src/inc/routes/upload.mjs @@ -1,10 +1,23 @@ import lib from "../lib.mjs"; import db from "../sql.mjs"; import cfg from "../config.mjs"; -import { getMinTags } from "../settings.mjs"; +import { getMinTags, canAnonDo, isAnonSession } from "../settings.mjs"; export default (router, tpl) => { - router.get(/^\/upload$/, lib.userauth, async (req, res) => { + const uploadAuth = (req, res, next) => { + if (!req.session) { + return res.redirect('/login'); + } + if (isAnonSession(req.session)) { + if (!canAnonDo('upload')) { + return res.redirect('/login'); + } + return next(); + } + return lib.userauth(req, res, next); + }; + + router.get(/^\/upload$/, uploadAuth, async (req, res) => { let maxfilesize = cfg.main.maxfilesize; if (req.session.admin || req.session.is_moderator) { maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier); diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs index 894186b..1cf4f1d 100644 --- a/src/inc/routes/user_halls.mjs +++ b/src/inc/routes/user_halls.mjs @@ -1,5 +1,6 @@ import db from "../sql.mjs"; import cfg from "../config.mjs"; +import { isAnonymizeSession } from "../settings.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; import fs from "fs/promises"; import path from "path"; @@ -43,8 +44,8 @@ export default (router, tpl) => { // List halls for a user router.get(/^\/user\/(?[^/]+)\/halls\/?$/, async (req, res) => { - if (cfg.main.guest_anonymize && !req.session) { - return res.redirect('/login'); + if (isAnonymizeSession(req.session)) { + return req.session ? res.redirect('/') : res.redirect('/login'); } if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) }); const ownerName = decodeURIComponent(req.params.owner); @@ -83,8 +84,8 @@ export default (router, tpl) => { // Item grid for a user hall router.get(/^\/user\/(?[^/]+)\/hall\/(?[^/]+)(?:\/p\/(?\d+))?\/?$/, async (req, res) => { - if (cfg.main.guest_anonymize && !req.session) { - return res.redirect('/login'); + if (isAnonymizeSession(req.session)) { + return req.session ? res.redirect('/') : res.redirect('/login'); } if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) }); const ownerName = decodeURIComponent(req.params.owner); @@ -132,8 +133,8 @@ export default (router, tpl) => { // Single item within a user hall router.get(/^\/user\/(?[^/]+)\/hall\/(?[^/]+)\/(?\d+)\/?$/, async (req, res) => { - if (cfg.main.guest_anonymize && !req.session) { - return res.redirect('/login'); + if (isAnonymizeSession(req.session)) { + return req.session ? res.redirect('/') : res.redirect('/login'); } if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) }); const ownerName = decodeURIComponent(req.params.owner); @@ -185,8 +186,8 @@ export default (router, tpl) => { data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : ''); data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : ''; data.item_has_dimensions = !!(item.width && item.height); - // When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL - if (cfg.main.guest_anonymize && !req.session) { + // When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL + if (isAnonymizeSession(req.session)) { if (item.src) item.src = null; item.username = 'anonymous'; item.author_banner_file = null; diff --git a/src/inc/routes/user_tags.mjs b/src/inc/routes/user_tags.mjs index 755d428..d317f0d 100644 --- a/src/inc/routes/user_tags.mjs +++ b/src/inc/routes/user_tags.mjs @@ -1,6 +1,7 @@ import db from "../../inc/sql.mjs"; import lib from "../../inc/lib.mjs"; import cfg from "../../inc/config.mjs"; +import { isAnonymizeSession } from "../settings.mjs"; import url from "url"; const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll @@ -100,6 +101,9 @@ export default (router, tpl) => { // API endpoint for lazy loading tags for a user router.get(/^\/api\/user\/(?[^\/]+)\/tags$/, async (req, res) => { + if (isAnonymizeSession(req.session)) { + return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Access denied" }) }); + } const userParam = decodeURIComponent(req.params.user); const u = await db` @@ -158,8 +162,8 @@ export default (router, tpl) => { // Main tags page router.get(/^\/user\/(?[^\/]+)\/tags$/, async (req, res) => { - if (cfg.main.guest_anonymize && !req.session) { - return res.redirect('/login'); + if (isAnonymizeSession(req.session)) { + return req.session ? res.redirect('/') : res.redirect('/login'); } const userParam = decodeURIComponent(req.params.user); diff --git a/src/inc/security.mjs b/src/inc/security.mjs index 9d8f6cd..cf9862b 100644 --- a/src/inc/security.mjs +++ b/src/inc/security.mjs @@ -317,8 +317,8 @@ export default new class { } // 3. Ban the hardware fingerprint + const associatedHws = new Set(); if (banHardware) { - const associatedHws = new Set(); if (targetHwFingerprint) associatedHws.add(targetHwFingerprint); if (userId) { @@ -348,9 +348,8 @@ export default new class { } // 4. Cascade to associated IPs + const associatedIps = new Set(); if (banIps) { - const associatedIps = new Set(); - if (userId) { const actIps = await db`select distinct ip from anon_activity_log where user_id = ${userId}`; for (const r of actIps) if (r.ip) associatedIps.add(r.ip); @@ -380,10 +379,12 @@ export default new class { for (const ip of associatedIps) { if (!ip || ip === 'unknown') continue; - const ipHash = this.hashIP(ip); + const isAlreadyHash = /^[a-f0-9]{64}$/i.test(ip); + const ipVal = ip; + const ipHash = isAlreadyHash ? ip : this.hashIP(ip); await db` insert into banned_ips (ip, ip_hash, banned_by, reason, expires_at) - values (${ip}, ${ipHash}, ${bannedBy}, ${reason}, ${expires}) + values (${ipVal}, ${ipHash}, ${bannedBy}, ${reason}, ${expires}) on conflict (ip) do update set reason = excluded.reason, expires_at = excluded.expires_at, @@ -393,6 +394,22 @@ export default new class { } } + // 5. Broadcast ban immediately via Postgres notify + try { + const hwList = Array.from(associatedHws).slice(0, 50); + const ipList = Array.from(associatedIps).slice(0, 50); + await db.notify('bans', JSON.stringify({ + userId, + fingerprint: targetFingerprint, + hwFingerprints: hwList, + ips: ipList, + reason: (reason || 'Banned anonymous identity').substring(0, 300), + expires + })); + } catch (e) { + console.error('[SECURITY] Error notifying bans channel:', e); + } + return { success: true, userId, fingerprint: targetFingerprint, hwFingerprint: targetHwFingerprint }; } }; diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index 0fbfa70..5f8a6bf 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -35,6 +35,100 @@ export const getEnableAnonymousAccess = () => { return true; }; +export const DEFAULT_ANON_PERMISSIONS = Object.freeze({ + upload: false, + comment: true, + comment_attachments: false, + comment_vote: true, + poll_vote: true, + tag: true, + tag_vote: true, + favorite: true, + rate_item: false, + filter: true, + exclude_tags: true, + anonymize_users: false, + allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'], + allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf'] +}); + +export const getAnonPermissions = () => { + const fromConfig = cfg.anonymous_permissions || cfg.websrv?.anonymous_permissions || {}; + return { + ...DEFAULT_ANON_PERMISSIONS, + ...fromConfig + }; +}; + +export const getAnonAnonymize = () => { + const perms = getAnonPermissions(); + if (typeof perms.anonymize_users === 'boolean') return perms.anonymize_users; + if (typeof perms.anon_anonymize === 'boolean') return perms.anon_anonymize; + if (cfg.main && typeof cfg.main.anon_anonymize === 'boolean') return cfg.main.anon_anonymize; + if (cfg.main && typeof cfg.main.anonymous_anonymize === 'boolean') return cfg.main.anonymous_anonymize; + if (typeof cfg.anon_anonymize === 'boolean') return cfg.anon_anonymize; + if (typeof cfg.anonymous_anonymize === 'boolean') return cfg.anonymous_anonymize; + return false; +}; + +export const isAnonymizeSession = (session) => { + if (!session || typeof session !== 'object' || !session.user) { + return !!(cfg.main?.guest_anonymize ?? cfg.guest_anonymize); + } + if (session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))) { + return getAnonAnonymize(); + } + return false; +}; + +export const canAnonDo = (action) => { + if (!getEnableAnonymousAccess()) return false; + const perms = getAnonPermissions(); + if (action === 'exclude_tags' || action === 'exclude_tag' || action === 'tag_exclude') { + if (perms.exclude_tags !== undefined) return !!perms.exclude_tags; + if (perms.exclude_tag !== undefined) return !!perms.exclude_tag; + if (perms.tag_exclude !== undefined) return !!perms.tag_exclude; + return perms.filter !== undefined ? !!perms.filter : true; + } + return perms[action] !== undefined ? !!perms[action] : !!DEFAULT_ANON_PERMISSIONS[action]; +}; + +export const getAnonAllowedModes = () => { + const perms = getAnonPermissions(); + return Array.isArray(perms.allowed_modes) ? perms.allowed_modes.map(m => String(m).toLowerCase()) : DEFAULT_ANON_PERMISSIONS.allowed_modes; +}; + +export const getAnonAllowedMimes = () => { + const perms = getAnonPermissions(); + return Array.isArray(perms.allowed_mimes) ? perms.allowed_mimes.map(m => String(m).toLowerCase()) : DEFAULT_ANON_PERMISSIONS.allowed_mimes; +}; + +export const canAnonMode = (mode) => { + if (!canAnonDo('filter')) return false; + const allowed = getAnonAllowedModes(); + const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; + const name = typeof mode === 'number' ? modeNames[mode] : String(mode).toLowerCase(); + return allowed.includes(name); +}; + +export const canAnonMime = (mime) => { + if (!canAnonDo('filter')) return false; + const allowed = getAnonAllowedMimes(); + return allowed.includes(String(mime).toLowerCase()); +}; + +export const isAnonSession = (session) => { + if (!session) return true; + if (session === true) return false; + if (typeof session !== 'object' || !session.user) return true; + return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))); +}; + +export const checkAnonPermission = (session, action) => { + if (!isAnonSession(session)) return true; + return canAnonDo(action); +}; + export const ensureAllItemsHaveSlugs = async () => { try { const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`; diff --git a/src/index.mjs b/src/index.mjs index 45fba1c..1a739af 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -20,7 +20,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, ensureAllItemsHaveSlugs } from "./inc/settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs } from "./inc/settings.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { createI18n } from "./inc/i18n.mjs"; import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs"; @@ -1636,6 +1636,8 @@ process.on('uncaughtException', err => { domain: cfg.main.url.domain, hide_comments_from_public: cfg.main.hide_comments_from_public, guest_anonymize: !!cfg.main.guest_anonymize, + anon_anonymize: false, + is_anonymized: false, git_hash: typeof gitHash !== 'undefined' ? gitHash : 'unknown', get motd() { return getMotd(); }, get manual_approval() { return getManualApproval(); }, @@ -1688,6 +1690,8 @@ process.on('uncaughtException', err => { get enable_expiring_uploads() { return getEnableExpiringUploads(); }, get enable_item_slugs() { return getEnableItemSlugs(); }, get enable_anonymous_access() { return getEnableAnonymousAccess(); }, + get anon_permissions() { return getAnonPermissions(); }, + get anon_permissions_json() { return JSON.stringify(getAnonPermissions()); }, default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)), allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false, nsfl_tag_id: cfg.nsfl_tag_id || 3, @@ -1828,10 +1832,16 @@ process.on('uncaughtException', err => { ? data.user_alternative_steuerung : (cfg.websrv.user_alternative_steuerung !== false)); + const activeSession = activeReq?.session || data?.session || null; + const isAnonymized = isAnonymizeSession(activeSession); + const anonAnonymize = getAnonAnonymize(); + data = Object.assign({}, globals, data || {}, { t: perRequestT, lang: perRequestLang, recaptcha_enabled: perRequestRecaptcha, + is_anonymized: isAnonymized, + anon_anonymize: anonAnonymize, user_alternative_infobox: useAltInfobox, user_alternative_steuerung: useAltSteuerung, user_banner_enabled: cfg.websrv.user_banner_enabled !== false, @@ -1843,6 +1853,8 @@ process.on('uncaughtException', err => { ? data.comment_display_mode : (cfg.websrv.default_comment_display_mode || 0)) }); + globals.is_anonymized = isAnonymized; + globals.anon_anonymize = anonAnonymize; // Random brand image per-render const brand = cfg.websrv.custom_brand_image; diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 28781d7..c2696d0 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -6,7 +6,7 @@ import { applyWordFilter } from "./inc/wordfilter.mjs"; import queue from "./inc/queue.mjs"; import path from "path"; import https from "https"; -import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs"; +import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs, canAnonDo, isAnonSession } from "./inc/settings.mjs"; import { parseMultipart, collectBody } from "./inc/multipart.mjs"; import f0cklib from "./inc/routeinc/f0cklib.mjs"; import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs"; @@ -121,8 +121,10 @@ export const handleUpload = async (req, res, self) => { return sendJson(res, { success: false, msg: 'Unauthorized' }, 401); } - if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) { - return sendJson(res, { success: false, msg: 'Uploading requires a registered account' }, 403); + if (isAnonSession(req.session)) { + if (!canAnonDo('upload')) { + return sendJson(res, { success: false, msg: 'Uploading requires a registered account or anonymous upload permission' }, 403); + } } // CSRF validation — required for browser sessions, skipped for API key auth. diff --git a/views/admin/users.html b/views/admin/users.html index 71118dd..15f3762 100644 --- a/views/admin/users.html +++ b/views/admin/users.html @@ -87,6 +87,99 @@ .btn-role { background: rgba(180, 100, 255, 0.15); color: #c084fc; border: 1px solid rgba(180, 100, 255, 0.3); } .btn-role:hover { background: rgba(180, 100, 255, 0.25); opacity: 1; } + .legacy-filter-btn { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 0 14px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + font-size: 0.85rem; + color: #aaa; + white-space: nowrap; + user-select: none; + transition: all 0.2s ease; + height: 42px; + box-sizing: border-box; + } + .legacy-filter-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; + } + .legacy-filter-btn.active { + background: rgba(var(--accent-rgb, 0, 150, 255), 0.15); + border-color: var(--accent); + color: var(--accent); + } + .legacy-filter-btn input { + cursor: pointer; + accent-color: var(--accent); + width: 15px; + height: 15px; + margin: 0; + } + + .admin-select-filter { + padding: 0 14px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #ccc; + font-size: 0.85rem; + outline: none; + cursor: pointer; + transition: all 0.2s ease; + height: 42px; + box-sizing: border-box; + } + .admin-select-filter:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; + } + .admin-select-filter:focus { + border-color: var(--accent); + } + .admin-select-filter.active { + background: rgba(var(--accent-rgb, 0, 150, 255), 0.15); + border-color: var(--accent); + color: var(--accent); + font-weight: 600; + } + .admin-select-filter option { + background: #1a1a1a; + color: #fff; + } + .admin-select-filter:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + .btn-filter-reset { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #aaa; + padding: 0 14px; + height: 42px; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + transition: all 0.2s; + box-sizing: border-box; + white-space: nowrap; + } + .btn-filter-reset:hover { + background: rgba(255, 60, 60, 0.15); + border-color: rgba(255, 60, 60, 0.3); + color: #ff6b6b; + } + /* Create User Modal */ #create-user-modal { display: none; @@ -236,28 +329,60 @@
-
+

User Management

-

Administration hub for {!! total !!} registered members.

+

Administration hub for {!! total !!} {{ totalLabel }}.

-
-
- - -
-
+ +
+
+ + +
+ + + + + + + + + + + + +
+
@@ -276,7 +401,7 @@
- No users matched your search. + {{ emptyMsg }}
@@ -502,28 +627,81 @@ var hasMore = {!! hasMore ? 'true' : 'false' !!}; var isLoading = false; var searchQuery = '{{ q }}'; + var onlyLegacy = {!! onlyLegacy ? 'true' : 'false' !!}; + var currentStatus = '{{ status }}'; + var currentRole = '{{ role }}'; var searchInput = document.getElementById('user-search'); + var statusFilter = document.getElementById('status-filter'); + var roleFilter = document.getElementById('role-filter'); + var legacyFilter = document.getElementById('legacy-filter'); + var legacyFilterLabel = document.getElementById('legacy-filter-label'); + var resetFiltersBtn = document.getElementById('reset-filters-btn'); + var totalLabel = document.getElementById('total-label'); var tableBody = document.getElementById('user-table-body'); var loadingTrigger = document.getElementById('loading-trigger'); var noUsersMsg = document.getElementById('no-users-msg'); var spinner = document.getElementById('search-spinner'); var countSpan = document.getElementById('total-count'); + function updateUrlAndControls() { + var urlParams = new URLSearchParams(); + if (searchQuery) urlParams.set('q', searchQuery); + if (onlyLegacy) { + urlParams.set('legacy', '1'); + } else { + if (currentStatus) urlParams.set('status', currentStatus); + if (currentRole) urlParams.set('role', currentRole); + } + var newUrl = window.location.pathname + (urlParams.toString() ? ('?' + urlParams.toString()) : ''); + window.history.replaceState({}, '', newUrl); + + // Update reset button + var hasFilter = !!(searchQuery || onlyLegacy || currentStatus || currentRole); + if (resetFiltersBtn) resetFiltersBtn.style.display = hasFilter ? 'inline-flex' : 'none'; + + // Update control states + if (statusFilter) { + statusFilter.disabled = onlyLegacy; + statusFilter.classList.toggle('active', !!currentStatus && !onlyLegacy); + } + if (roleFilter) { + roleFilter.disabled = onlyLegacy; + roleFilter.classList.toggle('active', !!currentRole && !onlyLegacy); + } + if (legacyFilterLabel) { + legacyFilterLabel.classList.toggle('active', onlyLegacy); + } + } + async function fetchUsers(page, q, append) { if (isLoading) return; isLoading = true; if (!append) spinner.style.display = 'block'; try { - var url = '/admin/users?page=' + page + '&q=' + encodeURIComponent(q); + var params = new URLSearchParams(); + params.set('page', page); + if (q) params.set('q', q); + if (onlyLegacy) { + params.set('legacy', '1'); + } else { + if (currentStatus) params.set('status', currentStatus); + if (currentRole) params.set('role', currentRole); + } + + var url = '/admin/users?' + params.toString(); var res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); // Update state from headers var total = res.headers.get('X-Total-Count'); var hasMoreHeader = res.headers.get('X-Has-More'); + var totalLabelHeader = res.headers.get('X-Total-Label'); + var emptyMsgHeader = res.headers.get('X-Empty-Msg'); if (total !== null) countSpan.textContent = total; + if (totalLabelHeader && totalLabel) totalLabel.textContent = totalLabelHeader; + if (emptyMsgHeader && noUsersMsg) noUsersMsg.textContent = emptyMsgHeader; if (hasMoreHeader !== null) hasMore = (hasMoreHeader === 'true'); var html = await res.text(); @@ -548,12 +726,74 @@ searchInput.addEventListener('input', function() { clearTimeout(searchTimeout); searchTimeout = setTimeout(function() { - searchQuery = searchInput.value; + searchQuery = searchInput.value.trim(); currentPage = 1; + updateUrlAndControls(); fetchUsers(1, searchQuery, false); }, 300); }); + // Status Filter Handling + if (statusFilter) { + statusFilter.addEventListener('change', function() { + currentStatus = this.value; + if (currentStatus && onlyLegacy) { + onlyLegacy = false; + if (legacyFilter) legacyFilter.checked = false; + } + updateUrlAndControls(); + currentPage = 1; + fetchUsers(1, searchQuery, false); + }); + } + + // Role Filter Handling + if (roleFilter) { + roleFilter.addEventListener('change', function() { + currentRole = this.value; + if (currentRole && onlyLegacy) { + onlyLegacy = false; + if (legacyFilter) legacyFilter.checked = false; + } + updateUrlAndControls(); + currentPage = 1; + fetchUsers(1, searchQuery, false); + }); + } + + // Legacy Only Filter Handling + if (legacyFilter) { + legacyFilter.addEventListener('change', function() { + onlyLegacy = this.checked; + if (onlyLegacy) { + currentStatus = ''; + currentRole = ''; + if (statusFilter) statusFilter.value = ''; + if (roleFilter) roleFilter.value = ''; + } + updateUrlAndControls(); + currentPage = 1; + fetchUsers(1, searchQuery, false); + }); + } + + // Reset All Filters + if (resetFiltersBtn) { + resetFiltersBtn.addEventListener('click', function() { + searchQuery = ''; + searchInput.value = ''; + onlyLegacy = false; + currentStatus = ''; + currentRole = ''; + if (legacyFilter) legacyFilter.checked = false; + if (statusFilter) statusFilter.value = ''; + if (roleFilter) roleFilter.value = ''; + updateUrlAndControls(); + currentPage = 1; + fetchUsers(1, '', false); + }); + } + // Infinite Scroll var observer = new IntersectionObserver(function(entries) { if (entries[0].isIntersecting && hasMore && !isLoading) { @@ -624,6 +864,14 @@ if (data.success) { closeCreateUserModal(); showFlash(data.msg, 'success'); + // If we were filtering by legacy or banned, reset filter so admin can see newly created user + if (onlyLegacy || currentStatus === 'banned') { + onlyLegacy = false; + currentStatus = ''; + if (legacyFilter) legacyFilter.checked = false; + if (statusFilter) statusFilter.value = ''; + updateUrlAndControls(); + } // Refresh the table from page 1 currentPage = 1; fetchUsers(1, searchQuery, false); diff --git a/views/banned.html b/views/banned.html index 3060e25..df223c4 100644 --- a/views/banned.html +++ b/views/banned.html @@ -1,13 +1,70 @@ @include(snippets/header) -
-
-

YOU ARE BANNED!

- -

Reason: {{ reason }}

-

Ban expires: {{ expires }}

-
- Leave -
+
+
+
+ + +
+

YOU ARE BANNED!

+ Banned +
+

+ Reason + {{ reason }} +

+

+ Ban expires + {{ expires }} +

+
+
+ Leave +
+
+ + +
+
+ +
+

YOU ARE NOT BANNED

+

+ Your account, connection, and device are in good standing. There are no active bans on this browser. +

+
+
+ Status + Clear / Active +
+
+ IP Address + {{ clientIp || '127.0.0.1' }} +
+
+ +
+
+
+ @include(snippets/footer) \ No newline at end of file diff --git a/views/index-partial.html b/views/index-partial.html index e23333d..b7e1342 100644 --- a/views/index-partial.html +++ b/views/index-partial.html @@ -3,7 +3,7 @@ @include(snippets/page-title)
@each(items as item) - +
@if(item.is_pinned) diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index 288623d..2810a0b 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -93,10 +93,10 @@
@if(user_alternative_infobox) -
+
- @if(session || !guest_anonymize) + @if(!is_anonymized) @if(item.author_avatar_file) @@ -115,8 +115,8 @@
- @if(session || !guest_anonymize){!! item.author_description || '' !!}@endif + @if(!is_anonymized){!! item.author_description || '' !!}@endif
@if(session) @@ -145,7 +145,7 @@ {{ (enable_item_slugs && item.slug) ? item.slug : item.id }} - @if(!user_alternative_infobox) — [@if(session || !guest_anonymize){!! item.author_display_name || item.username || 'unknown' !!}@elseanonymous@endif] @endif + @if(!user_alternative_infobox) — [@if(!is_anonymized){!! item.author_display_name || item.username || 'unknown' !!}@elseanonymous@endif] @endif @if(!user_alternative_infobox) @if(item.is_oc) — OC@endif @endif @@ -205,8 +205,8 @@ {!! tag.tag !!} @else - - {!! tag.tag !!}@if(is_mod_or_admin) @endif + + {!! tag.tag !!}@if(tag.can_exclude)@endif@if(is_mod_or_admin) @endif @endif @endeach @@ -227,7 +227,7 @@ @each(item.favorites as fav) @if(fav.hide_fav_badge && (!session || session.user !== fav.user)) - @elseif(!session && guest_anonymize) + @elseif(is_anonymized) @else @@ -252,6 +252,8 @@ @if(item.is_comments_locked) data-is-locked="true" @endif> @if(item.is_comments_locked && !is_mod_or_admin)
🔒 Comments are disabled on this thread.
+ @elseif(session && session.is_anon && !anon_permissions.comment) +
🔒 Anonymous comments are disabled.
@else
diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html index 400673f..8f8a118 100644 --- a/views/item-partial-modern.html +++ b/views/item-partial-modern.html @@ -21,6 +21,8 @@ @if(item.is_comments_locked) data-is-locked="true" @endif> @if(item.is_comments_locked && !is_mod_or_admin)
🔒 Comments are disabled on this thread.
+ @elseif(session && session.is_anon && !anon_permissions.comment) +
🔒 Anonymous comments are disabled.
@else
@@ -51,8 +53,8 @@ {!! tag.tag !!} @else - - {!! tag.tag !!}@if(is_mod_or_admin) @endif + + {!! tag.tag !!}@if(tag.can_exclude)@endif@if(is_mod_or_admin) @endif @endif @endeach @@ -138,7 +140,7 @@
- {{ (enable_item_slugs && item.slug) ? item.slug : item.id }} — [@if(session || !guest_anonymize){!! item.author_display_name || item.username || 'unknown' !!}@elseanonymous@endif] @if(item.is_oc) — OC@endif + {{ (enable_item_slugs && item.slug) ? item.slug : item.id }} — [@if(!is_anonymized){!! item.author_display_name || item.username || 'unknown' !!}@elseanonymous@endif] @if(item.is_oc) — OC@endif @if(halls_enabled && item.primaryHall) {{ item.primaryHall.name }}@if(item.otherHalls && item.otherHalls.length)+{{ item.otherHalls.length }}@each(item.otherHalls as oh){{ oh.name }}@endeach@endif @@ -185,7 +187,7 @@ @each(item.favorites as fav) @if(fav.hide_fav_badge && (!session || session.user !== fav.user)) - @elseif(!session && guest_anonymize) + @elseif(is_anonymized) @else diff --git a/views/scroller.html b/views/scroller.html index 090f682..3198c74 100644 --- a/views/scroller.html +++ b/views/scroller.html @@ -942,6 +942,19 @@ window.scrollerLoggedIn = @if(typeof session !== 'undefined' && session)true@else false@endif; window.scrollerIsMod = @if(typeof session !== 'undefined' && session && (session.admin || session.is_moderator))true@else false@endif; window.scrollerCsrf = "@if(typeof session !== 'undefined' && session){{ session.csrf_token || '' }}@else@endif"; + window.f0ckSession = { + logged_in: window.scrollerLoggedIn, + user: @if(typeof session !== 'undefined' && session && session.user)"{{ session.user }}"@else null@endif, + is_anon: @if(typeof session !== 'undefined' && session && session.is_anon)true@else false@endif, + anon_permissions: {{ anon_permissions_json || '{}' }}, + csrf_token: window.scrollerCsrf, + guest_anonymize: @if(guest_anonymize) true @else false @endif, + anon_anonymize: @if(anon_anonymize) true @else false @endif, + is_anonymized: @if(is_anonymized) true @else false @endif, + excluded_tags: @if(typeof session !== 'undefined' && session && session.excluded_tags) {{ JSON.stringify(session.excluded_tags) }} @else [] @endif, + development: @if(development) true @else false @endif + }; + window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.scrollerEnableNsfl = {{ enable_nsfl ? 'true' : 'false' }}; window.scrollerEnableSwf = {{ enable_swf ? 'true' : 'false' }}; window.scrollerRuffleVolume = @if(typeof session !== 'undefined' && session && session.ruffle_volume !== undefined && session.ruffle_volume !== null){{ session.ruffle_volume }}@else 0.5@endif; @@ -1110,27 +1123,52 @@
+ @js + const _isScrollerAnon = !(typeof session !== 'undefined' && session && session.user && !session.is_anon); + const _scrollerAllowedModes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_modes) || ['sfw', 'nsfw', 'untagged', 'all', 'nsfl']; + const _scrollerShowSfw = !_isScrollerAnon || _scrollerAllowedModes.includes('sfw'); + const _scrollerShowNsfw = (typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('nsfw')); + const _scrollerShowNsfl = enable_nsfl && ((typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('nsfl'))); + const _scrollerShowAll = (typeof session !== 'undefined' && session && !session.is_anon) || (_isScrollerAnon && _scrollerAllowedModes.includes('all')); + const _scrollerShowUntagged = (typeof session !== 'undefined' && session && (session.admin || session.is_moderator)) || (_isScrollerAnon && _scrollerAllowedModes.includes('untagged')); + const _scrollerActiveModes = [_scrollerShowSfw ? 'sfw' : null, _scrollerShowNsfw ? 'nsfw' : null, _scrollerShowNsfl ? 'nsfl' : null, _scrollerShowUntagged ? 'untagged' : null].filter(Boolean); + const _scrollerIsSingleMode = _isScrollerAnon && _scrollerActiveModes.length === 1 && !_scrollerShowAll; + const _scrollerSingleMode = _scrollerIsSingleMode ? _scrollerActiveModes[0] : null; + + const _scrollerAllowedMimes = (typeof anon_permissions !== 'undefined' && anon_permissions && anon_permissions.allowed_mimes) || ['image', 'video', 'audio', 'flash', 'pdf']; + const _scrollerIsSingleMime = _isScrollerAnon && _scrollerAllowedMimes.length === 1; + const _scrollerSingleMime = _scrollerIsSingleMime ? _scrollerAllowedMimes[0] : null; + @endjs
+ @if(_scrollerIsSingleMode) + + @else + @if(_scrollerShowSfw) - @if(session) + @endif + @if(_scrollerShowNsfw) @endif - @if(session && enable_nsfl) + @if(_scrollerShowNsfl) @endif - @if(session) + @if(_scrollerShowAll) @endif - @if(session && (session.admin || session.is_moderator)) + @if(_scrollerShowUntagged) @endif + @endif
+ @if(_scrollerIsSingleMime) + + @else @if(scroller_mime_cats.includes('video')) @@ -1141,6 +1179,7 @@ @if(scroller_mime_cats.includes('audio')) @endif + @endif
diff --git a/views/snippets/excluded-tags-modal.html b/views/snippets/excluded-tags-modal.html index 6acee67..276e220 100644 --- a/views/snippets/excluded-tags-modal.html +++ b/views/snippets/excluded-tags-modal.html @@ -1,40 +1,75 @@