From 1477d566585c47c07dc0378cd492074d063a418c Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sat, 19 Sep 2026 06:20:37 +0200 Subject: [PATCH] alotta good shit --- config_example.json | 2 + migrations/add_passkey_credentials.sql | 31 + migrations/add_report_categories.sql | 1 + public/s/css/f0ckm.css | 118 +- public/s/js/admin.js | 123 +- public/s/js/anon_ssh.js | 1537 +++++++++--------------- public/s/js/f0ckm.js | 498 +++++++- public/s/js/mod-reports.js | 294 +++++ public/s/js/scroller.js | 4 +- public/s/js/sidebar-activity.js | 2 +- public/s/js/v0ck.js | 32 +- src/brand_image_handler.mjs | 201 ++++ src/inc/anon_auth.mjs | 138 +-- src/inc/lib.mjs | 19 +- src/inc/routeinc/f0cklib.mjs | 15 +- src/inc/routes/admin.mjs | 3 +- src/inc/routes/ajax.mjs | 48 +- src/inc/routes/apiv2/anon.mjs | 524 +++++--- src/inc/routes/apiv2/index.mjs | 143 ++- src/inc/routes/apiv2/settings.mjs | 274 +++++ src/inc/routes/apiv2/upload.mjs | 16 +- src/inc/routes/comments.mjs | 25 +- src/inc/routes/external.mjs | 24 +- src/inc/routes/index.mjs | 27 +- src/inc/routes/mod.mjs | 20 +- src/inc/routes/notifications.mjs | 18 +- src/inc/routes/reports.mjs | 23 +- src/inc/settings.mjs | 9 + src/inc/trigger/parser.mjs | 4 +- src/inc/webauthn.mjs | 474 ++++++++ src/index.mjs | 167 ++- src/upload_handler.mjs | 26 +- views/admin.html | 123 +- views/item-partial-legacy.html | 84 +- views/item-partial-modern.html | 8 +- views/mod/audit.html | 105 +- views/mod_reports.html | 656 ++++------ views/notifications.html | 4 + views/scroller.html | 8 + views/settings.html | 120 +- views/snippets/footer.html | 32 +- views/snippets/header.html | 4 +- views/snippets/item-media.html | 9 +- views/snippets/navbar.html | 437 ++++--- views/user-partial.html | 2 + 45 files changed, 4363 insertions(+), 2069 deletions(-) create mode 100644 migrations/add_passkey_credentials.sql create mode 100644 migrations/add_report_categories.sql create mode 100644 public/s/js/mod-reports.js create mode 100644 src/brand_image_handler.mjs create mode 100644 src/inc/webauthn.mjs diff --git a/config_example.json b/config_example.json index 3ed13a4..df9b90b 100644 --- a/config_example.json +++ b/config_example.json @@ -31,6 +31,7 @@ ], "enable_pdf": false, "enable_nsfl": false, + "enable_comments": true, "enable_private_uploads": true, "enable_expiring_uploads": true, "default_upload_visibility": 0, @@ -124,6 +125,7 @@ "halls_enabled": true, "userhalls_enabled": true, "enable_userhall_image_upload": true, + "enable_oc": true, "abyss_enabled": true, "meme_creator": true, "enable_cleanup": false, diff --git a/migrations/add_passkey_credentials.sql b/migrations/add_passkey_credentials.sql new file mode 100644 index 0000000..dbc33d6 --- /dev/null +++ b/migrations/add_passkey_credentials.sql @@ -0,0 +1,31 @@ +-- passkey_credentials: stores WebAuthn credential records for all users +-- (both registered accounts and anonymous shadow users) +CREATE TABLE IF NOT EXISTS public.passkey_credentials ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, -- base64url-encoded credentialId + public_key_spki TEXT NOT NULL, -- base64-encoded DER SPKI (ES-256 / P-256) + sign_count BIGINT NOT NULL DEFAULT 0, -- replay-attack counter + aaguid TEXT, -- authenticator AAGUID (informational) + name TEXT, -- user-assigned label ("Bitwarden", "iPhone") + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_used TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON public.passkey_credentials(credential_id); +CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON public.passkey_credentials(user_id); + +-- Extend anon_identities to support passkey-based identities. +-- New passkey rows use credential_id; legacy SSH rows retain pubkey/fingerprint. +-- pubkey and fingerprint are made nullable to allow passkey-only rows. +ALTER TABLE public.anon_identities + ADD COLUMN IF NOT EXISTS credential_id TEXT UNIQUE; + +ALTER TABLE public.anon_identities + ALTER COLUMN pubkey DROP NOT NULL; + +ALTER TABLE public.anon_identities + ALTER COLUMN fingerprint DROP NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_anon_identities_credential_id + ON public.anon_identities(credential_id); diff --git a/migrations/add_report_categories.sql b/migrations/add_report_categories.sql new file mode 100644 index 0000000..f9c5ff1 --- /dev/null +++ b/migrations/add_report_categories.sql @@ -0,0 +1 @@ +ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS categories text[] DEFAULT '{}'; diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 394e2ae..5a9a3a6 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -14062,6 +14062,79 @@ textarea.mod-reason:focus { font-size: 0.75em; } +/* Audit filter bar */ +.audit-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 18px; + padding: 12px 14px; + background: var(--badge-bg); + border: 1px solid var(--nav-border-color); + border-radius: 8px; +} + +.audit-filter-input { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--nav-border-color); + border-radius: 5px; + color: inherit; + font-size: 0.85em; + padding: 5px 10px; + outline: none; + transition: border-color 0.2s; +} + +.audit-filter-input:focus { + border-color: var(--accent); +} + +select.audit-filter-input { + min-width: 180px; +} + +input.audit-filter-input { + min-width: 160px; +} + +.audit-filter-btn { + cursor: pointer; + font-size: 0.8em; + padding: 5px 12px; + border: none; + line-height: 1.6; +} + +.audit-filter-clear { + opacity: 0.7; +} + +.audit-filter-clear:hover { + opacity: 1; +} + +/* Header right group (time + id) */ +.audit-card-header-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +/* Entry ID badge */ +.audit-entry-id { + font-size: 0.72em; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + color: var(--accent); + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--nav-border-color); + border-radius: 4px; + padding: 1px 6px; + letter-spacing: 0.03em; + white-space: nowrap; +} + .audit-diff { background: rgba(0, 0, 0, 0.3); border-radius: 4px; @@ -17393,7 +17466,7 @@ textarea#profile_description { .user-infobox-block { display: flex; - gap: 10px; + gap: 5px; background: var(--comment-bg); border: 1px solid var(--author-border, rgba(0, 255, 0, 0.2)); line-height: 1; @@ -17401,9 +17474,8 @@ textarea#profile_description { align-items: flex-start; width: 100%; max-width: 100%; - min-height: 90px; + min-height: 40px; box-sizing: border-box; - margin-bottom: 10px; } /* ── Banner inside alternative infobox ──────────────────────────────── */ @@ -17435,8 +17507,8 @@ textarea#profile_description { .user-infobox-avatar img { - width: 64px; - height: 64px; + width: auto; + height: 22px; object-fit: cover; } @@ -17447,12 +17519,23 @@ textarea#profile_description { } .user-infobox-header { - display: flex; - justify-content: space-between; + display: grid; align-items: center; - margin-bottom: 6px; padding-bottom: 6px; - border-bottom: 1px solid var(--nav-border-color); + grid-template-columns: auto 1fr; +} + +.user-infobox-mime { + background: black; + padding: 4px; + margin: 2px; + font-size: 6px; + border-radius: 3px; + font-weight: bold; + text-transform: uppercase; + cursor: default; + user-select: none; + align-self: self-end; } .user-infobox-actions i { @@ -17485,6 +17568,7 @@ textarea#profile_description { font-size: 0.8em; color: #888; letter-spacing: 0.5px; + text-align: right; } .timestamp-link { @@ -21592,6 +21676,7 @@ div#flash, background: var(--nav-bg, #2b2b2b); color: var(--white, #fff); border: 1px solid var(--nav-border-color, #444); + margin-right: 4px; } .bulk-btn-close:hover { @@ -21600,6 +21685,21 @@ div#flash, border-color: #ff4444 !important; } +.bulk-btn-danger { + background: transparent !important; + color: #ff5555 !important; + border: 1px solid #ff5555 !important; + font-weight: bold !important; + box-shadow: none !important; +} + +.bulk-btn-danger:hover { + background: #ff4444 !important; + color: #fff !important; + border-color: #ff4444 !important; + filter: none !important; +} + /* Bulk rating dropdown wrapper */ .bulk-dropdown-wrap { position: relative; diff --git a/public/s/js/admin.js b/public/s/js/admin.js index 3ab8972..0ccc225 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -396,18 +396,48 @@ } }; - const deleteButtonEvent = async e => { - if (e) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - } - const ctx = getContext(); - if (!ctx) return; - const { postid, poster, authorId } = ctx; - + const deleteSubItemEvent = async (subf0ck, postid) => { + if (!subf0ck || !postid) return; if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded'); + const subDesc = subf0ck.display_index + ? `Slide #${subf0ck.display_index}` + : `Slide (${subf0ck.slug || subf0ck.id})`; + + ModAction.confirm( + 'Delete Slide from Album', + `Are you sure you want to delete ${subDesc} from this album?
The rest of the album will remain intact.`, + async (reason) => { + const res = await post("/api/v2/admin/delete-album-item", { + postid: postid, + sub_id: subf0ck.id, + sub_slug: subf0ck.slug, + order_index: subf0ck.order_index, + reason: reason + }); + if (!res.success) { + throw new Error(res.msg || 'Failed to delete album item'); + } + if (res.post_deleted) { + if (window.flashMessage) window.flashMessage('Album deleted (no remaining items)', 2500, 'info'); + const mediaObj = document.querySelector('.media-object'); + if (mediaObj) { + mediaObj.innerHTML = '

Album Deleted

The album has been removed.

'; + } + } else { + if (window.albumGallery && typeof window.albumGallery.removeSubf0ck === 'function') { + window.albumGallery.removeSubf0ck(subf0ck.id || subf0ck.slug || subf0ck.order_index); + } else { + window.location.reload(); + } + if (window.flashMessage) window.flashMessage('Slide deleted from album', 2500, 'success'); + } + }, + { allowEmpty: window.f0ckSession?.is_admin, confirmText: 'Delete Slide' } + ); + }; + + const deleteEntirePost = (postid, poster, authorId) => { const i18n = window.f0ckI18n || {}; const confirmTitle = i18n.item_delete_title || 'Delete Item'; const posterStr = poster @@ -438,6 +468,68 @@ }, { allowEmpty: window.f0ckSession?.is_admin }); }; + const deleteButtonEvent = async e => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + const ctx = getContext(); + if (!ctx) return; + const { postid, poster, authorId } = ctx; + + if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded'); + + const isAlbum = !!(window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function'); + const curSub = isAlbum ? window.albumGallery.getCurrentSubf0ck() : null; + + if (isAlbum && curSub) { + const subDesc = curSub.display_index ? `Slide ${curSub.display_index}` : 'Current Slide'; + const choiceHtml = ` +
+ This post is an album containing multiple slides. What would you like to delete? +
+
+ + +
+ `; + + ModAction.confirm('Delete Options', choiceHtml, () => {}, { + hideReason: true, + hideConfirm: true, + unsafeContent: true, + cancelText: 'Cancel' + }); + + setTimeout(() => { + const modal = document.getElementById('mod-action-modal'); + if (!modal) return; + const subBtn = modal.querySelector('#btn-choice-delete-sub'); + const albumBtn = modal.querySelector('#btn-choice-delete-album'); + if (subBtn) { + subBtn.onclick = () => { + modal.style.display = 'none'; + deleteSubItemEvent(curSub, postid); + }; + } + if (albumBtn) { + albumBtn.onclick = () => { + modal.style.display = 'none'; + deleteEntirePost(postid, poster, authorId); + }; + } + }, 50); + return; + } + + deleteEntirePost(postid, poster, authorId); + }; + let tmptt = null; const editTagEvent = async e => { e.preventDefault(); @@ -504,6 +596,17 @@ addtagClick(e); } else if (target.closest("#a_delete")) { deleteButtonEvent(e); + } else if (target.closest(".album-sub-delete-btn, #a_delete_sub")) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const ctx = getContext(); + const curSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') + ? window.albumGallery.getCurrentSubf0ck() + : null; + if (ctx && curSub) { + deleteSubItemEvent(curSub, ctx.postid); + } } else if (target.matches('#tags .badge > a[href*="/tag/"]')) { editTagEvent(e); } else if (target.closest('.admin-deltag') || target.closest('.removetag')) { diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js index 426632a..83539e5 100644 --- a/public/s/js/anon_ssh.js +++ b/public/s/js/anon_ssh.js @@ -1,826 +1,466 @@ /** - * f0ckm Anonymous OpenSSH Ed25519 Identity Manager - * Provides client-side Ed25519 key generation, OpenSSH key formatting, - * signing, and authentication without login. + * f0ckm Anonymous Passkey Identity Manager + * + * Replaces the old OpenSSH Ed25519 approach. + * Private keys NEVER touch localStorage — they live in the OS / Bitwarden credential store. + * + * Flow: + * First visit: "Login as Anonymous" → register/begin → browser passkey prompt → register/finish → session + * Return visit: "Login as Anonymous" → auth/begin → browser passkey picker → auth/finish → session + * + * For registered users: + * Settings page calls window.f0ckPasskeyManager.addPasskey() to add a passkey. */ (function () { 'use strict'; - const PKCS8_HEADER = new Uint8Array([ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20 - ]); + // ─── base64url helpers (browser) ─────────────────────────────────────────── - const STORAGE_KEY_PRIV = 'f0ck_anon_ssh_priv'; - const STORAGE_KEY_PUB = 'f0ck_anon_ssh_pub'; - const STORAGE_KEY_FP = 'f0ck_anon_ssh_fp'; + function b64urlToArr(b64) { + const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/')); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; + } - function bytesToBase64(bytes) { - let binary = ''; - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); + function arrToB64url(buf) { + const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf); + let bin = ''; + arr.forEach(b => bin += String.fromCharCode(b)); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + // ─── Hardware fingerprint (kept for ban enforcement) ────────────────────── + + async function getHardwareFingerprint() { + try { + const cached = localStorage.getItem('f0ck_anon_hw_fp'); + if (cached) return cached; + } catch (e) {} + + try { + let glVendor = '', glRenderer = '', glLimits = ''; + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + if (gl) { + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + if (ext) { + glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || ''; + glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || ''; + } + glLimits = [ + gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0, + gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0, + gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0, + gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0, + gl.getParameter(gl.MAX_VARYING_VECTORS) || 0, + gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0 + ].join(','); + } + } catch (e) {} + + 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) {} + + const concurrency = navigator.hardwareConcurrency || 0; + const memory = navigator.deviceMemory || 0; + const platform = navigator.platform || ''; + const screenInfo = [ + window.screen ? window.screen.width : 0, + window.screen ? window.screen.height : 0, + window.screen ? window.screen.colorDepth : 0, + window.devicePixelRatio || 1 + ].join('x'); + const touchPoints = navigator.maxTouchPoints || 0; + + let canvasFp = ''; + try { + const c2d = document.createElement('canvas'); + c2d.width = 240; c2d.height = 60; + const ctx = c2d.getContext('2d'); + if (ctx) { + ctx.fillStyle = '#f60'; ctx.fillRect(10, 5, 60, 20); + ctx.fillStyle = '#069'; ctx.font = '14pt Arial, sans-serif'; + ctx.fillText('f0ck.dev 😃', 4, 35); + 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) {} + + let audioFp = ''; + try { + const AudioCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext; + if (AudioCtx) { + const actx = new AudioCtx(1, 44100, 44100); + const osc = actx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(10000, actx.currentTime); + const comp = actx.createDynamicsCompressor(); + comp.threshold.setValueAtTime(-50, actx.currentTime); + comp.knee.setValueAtTime(40, actx.currentTime); + comp.ratio.setValueAtTime(12, actx.currentTime); + comp.attack.setValueAtTime(0, actx.currentTime); + comp.release.setValueAtTime(0.25, actx.currentTime); + osc.connect(comp); comp.connect(actx.destination); osc.start(0); + const buf = await actx.startRendering(); + const ch = buf.getChannelData(0); + let sum = 0; + for (let i = 4500; i < Math.min(ch.length, 5000); i++) sum += Math.abs(ch[i] || 0); + audioFp = sum.toFixed(7); + } + } catch (e) {} + + const raw = [glVendor, glRenderer, glLimits, gpuArch, concurrency, memory, platform, screenInfo, touchPoints, canvasFp, audioFp].join('~~~'); + const hashBuf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw)); + const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join(''); + const fp = `HW:${hashHex}`; + try { localStorage.setItem('f0ck_anon_hw_fp', fp); } catch (e) {} + return fp; + } catch (err) { + console.warn('[ANON_PASSKEY] Failed to compute hardware fingerprint:', err); + return null; } - return window.btoa(binary); } - function base64ToBytes(base64) { - const binary = window.atob(base64.replace(/\s+/g, '')); - const len = binary.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; + // ─── Tombstone (ban state persisted client-side) ─────────────────────────── + + function getTombstone() { + try { return JSON.parse(localStorage.getItem('f0ck_anon_tombstone') || 'null'); } catch (e) { return null; } } - function bytesToHex(bytes) { - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + function setTombstone(t) { + try { + localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(t)); + if (t && t.banned) { + document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(t))}; Path=/; Max-Age=31536000; SameSite=Lax`; + } + } catch (e) {} } - function hexToBytes(hex) { - const clean = hex.replace(/[^0-9a-fA-F]/g, ''); - const bytes = new Uint8Array(clean.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16); - } - return bytes; - } + // ─── AnonPasskey class ──────────────────────────────────────────────────── - function writeUInt32BE(buf, value, offset) { - buf[offset] = (value >>> 24) & 0xff; - buf[offset + 1] = (value >>> 16) & 0xff; - buf[offset + 2] = (value >>> 8) & 0xff; - buf[offset + 3] = value & 0xff; - } - - function readUInt32BE(buf, offset) { - return ( - (buf[offset] << 24) | - (buf[offset + 1] << 16) | - (buf[offset + 2] << 8) | - buf[offset + 3] - ) >>> 0; - } - - function concatUint8Arrays(arrays) { - const totalLen = arrays.reduce((acc, a) => acc + a.length, 0); - const result = new Uint8Array(totalLen); - let offset = 0; - for (const arr of arrays) { - result.set(arr, offset); - offset += arr.length; - } - return result; - } - - class AnonSSH { + class AnonPasskey { constructor() { - this.cryptoKey = null; - this.pubkey = null; - this.fingerprint = null; - this.shortFingerprint = null; - this.rawPub = null; - this.rawSeed = null; this.isSessionReady = false; - this.hwFingerprint = null; - try { - this.hwFingerprint = localStorage.getItem('f0ck_anon_hw_fp') || null; - } catch (e) {} + this._loginInProgress = false; } - /** - * Compute a hardware-level fingerprint based on GPU, CPU, RAM, screen, and Web Audio DSP. - * Survives across private browsing windows and different browsers on the same physical device. - */ - 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) {} + // ── Check WebAuthn support ────────────────────────────────────────────── - try { - let glVendor = ''; - let glRenderer = ''; - let glLimits = ''; - try { - const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); - if (gl) { - const ext = gl.getExtension('WEBGL_debug_renderer_info'); - if (ext) { - glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || ''; - glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || ''; - } - glLimits = [ - gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0, - gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0, - gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0, - gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0, - gl.getParameter(gl.MAX_VARYING_VECTORS) || 0, - gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0 - ].join(','); - } - } catch (e) {} + get supported() { + return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create); + } - // 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) {} + // ── UI helpers ────────────────────────────────────────────────────────── - // 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.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; - if (AudioContext) { - const actx = new AudioContext(1, 44100, 44100); - const osc = actx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.setValueAtTime(10000, actx.currentTime); - - const comp = actx.createDynamicsCompressor(); - comp.threshold.setValueAtTime(-50, actx.currentTime); - comp.knee.setValueAtTime(40, actx.currentTime); - comp.ratio.setValueAtTime(12, actx.currentTime); - comp.reduction.setValueAtTime(-20, actx.currentTime); - comp.attack.setValueAtTime(0, actx.currentTime); - comp.release.setValueAtTime(0.25, actx.currentTime); - - osc.connect(comp); - comp.connect(actx.destination); - osc.start(0); - - const renderedBuffer = await actx.startRendering(); - const channel = renderedBuffer.getChannelData(0); - let sum = 0; - const end = Math.min(channel.length, 5000); - for (let i = 4500; i < end; i++) { - sum += Math.abs(channel[i] || 0); - } - audioFp = sum.toFixed(7); - } - } 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, - inputHardware, - canvasFp, - audioFp, - mathPrecision - ].join('~~~'); - - const encoder = new TextEncoder(); - const data = encoder.encode(rawHardware); - 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); - return null; + updateNavUI(isAnon) { + const icon = document.getElementById('nav-visitor-icon'); + const label = document.getElementById('nav-anon-label'); + if (icon) { + icon.classList.toggle('fa-user-secret', isAnon); + icon.classList.toggle('fa-user', !isAnon); } - } + if (label) label.textContent = isAnon ? 'anonymous' : 'guest'; - getDomain() { - return window.f0ckDomain || window.location.hostname || 'f0ck.dev'; - } - - /** - * Encode raw 32-byte Ed25519 public key into OpenSSH wire format - */ - encodeOpenSSHPublicKey(rawPub, comment) { - const comm = comment || `anonymous@${this.getDomain()}`; - const keyType = new TextEncoder().encode('ssh-ed25519'); - - const wirePub = new Uint8Array(4 + keyType.length + 4 + rawPub.length); - writeUInt32BE(wirePub, keyType.length, 0); - wirePub.set(keyType, 4); - writeUInt32BE(wirePub, rawPub.length, 4 + keyType.length); - wirePub.set(rawPub, 4 + keyType.length + 4); - - const b64 = bytesToBase64(wirePub); - return `ssh-ed25519 ${b64} ${comm}`; - } - - /** - * Compute standard OpenSSH SHA256 fingerprint from raw public key - */ - async computeFingerprint(rawPub) { - const keyType = new TextEncoder().encode('ssh-ed25519'); - const wirePub = new Uint8Array(4 + keyType.length + 4 + rawPub.length); - writeUInt32BE(wirePub, keyType.length, 0); - wirePub.set(keyType, 4); - writeUInt32BE(wirePub, rawPub.length, 4 + keyType.length); - wirePub.set(rawPub, 4 + keyType.length + 4); - - const hashBuffer = await window.crypto.subtle.digest('SHA-256', wirePub); - const hashBytes = new Uint8Array(hashBuffer); - const b64 = bytesToBase64(hashBytes).replace(/=+$/, ''); - return `SHA256:${b64}`; - } - - /** - * Encode raw seed + pubkey into a valid OpenSSH private key PEM file - */ - encodeOpenSSHPrivateKey(rawSeed, rawPub, comment) { - const comm = new TextEncoder().encode(comment || `anonymous@${this.getDomain()}`); - const keyType = new TextEncoder().encode('ssh-ed25519'); - const none = new TextEncoder().encode('none'); - const magic = new TextEncoder().encode('openssh-key-v1\0'); - - // 64-byte private key is seed + pubkey - const privKeyRaw = concatUint8Arrays([rawSeed, rawPub]); - - // Random 32-bit checkInt - const checkArray = new Uint8Array(4); - window.crypto.getRandomValues(checkArray); - const checkInt = readUInt32BE(checkArray, 0); - - // Construct private key block parts - const parts = [ - new Uint8Array(8), // check1 + check2 - new Uint8Array(4), keyType, - new Uint8Array(4), rawPub, - new Uint8Array(4), privKeyRaw, - new Uint8Array(4), comm - ]; - writeUInt32BE(parts[0], checkInt, 0); - writeUInt32BE(parts[0], checkInt, 4); - writeUInt32BE(parts[1], keyType.length, 0); - writeUInt32BE(parts[3], rawPub.length, 0); - writeUInt32BE(parts[5], privKeyRaw.length, 0); - writeUInt32BE(parts[7], comm.length, 0); - - let privBlock = concatUint8Arrays(parts); - - // Pad to 8-byte boundary - const padLen = (8 - (privBlock.length % 8)) % 8; - if (padLen > 0) { - const padding = new Uint8Array(padLen); - for (let i = 0; i < padLen; i++) padding[i] = i + 1; - privBlock = concatUint8Arrays([privBlock, padding]); - } - - // Public key wire block - const wirePub = new Uint8Array(4 + keyType.length + 4 + rawPub.length); - writeUInt32BE(wirePub, keyType.length, 0); - wirePub.set(keyType, 4); - writeUInt32BE(wirePub, rawPub.length, 4 + keyType.length); - wirePub.set(rawPub, 4 + keyType.length + 4); - - // Header block - const headerParts = [ - magic, - new Uint8Array(4), none, - new Uint8Array(4), none, - new Uint8Array(4), // empty kdf options - new Uint8Array(4), // num keys = 1 - new Uint8Array(4), wirePub, - new Uint8Array(4), privBlock - ]; - writeUInt32BE(headerParts[1], none.length, 0); - writeUInt32BE(headerParts[3], none.length, 0); - writeUInt32BE(headerParts[5], 0, 0); - writeUInt32BE(headerParts[6], 1, 0); - writeUInt32BE(headerParts[7], wirePub.length, 0); - writeUInt32BE(headerParts[9], privBlock.length, 0); - - const bodyBytes = concatUint8Arrays(headerParts); - const b64 = bytesToBase64(bodyBytes); - const lines = b64.match(/.{1,70}/g) || [b64]; - - return `-----BEGIN OPENSSH PRIVATE KEY-----\n${lines.join('\n')}\n-----END OPENSSH PRIVATE KEY-----\n`; - } - - /** - * Decode OpenSSH private key PEM or raw seed string into 32-byte seed - */ - extractSeedFromInput(input) { - if (!input || typeof input !== 'string') { - throw new Error('Key input is required'); - } - const trimmed = input.trim(); - - // Check if OpenSSH PEM format - if (trimmed.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) { - const cleanB64 = trimmed - .replace(/-----[^\n]+-----/g, '') - .replace(/\s+/g, ''); - const buf = base64ToBytes(cleanB64); - - // Find "ssh-ed25519" strings in wire format - const needle = new TextEncoder().encode('ssh-ed25519'); - let firstIdx = -1; - for (let i = 0; i <= buf.length - needle.length; i++) { - let match = true; - for (let j = 0; j < needle.length; j++) { - if (buf[i + j] !== needle[j]) { match = false; break; } - } - if (match) { firstIdx = i; break; } - } - if (firstIdx === -1) throw new Error('Not an Ed25519 OpenSSH private key'); - - // Second occurrence is in private block - let secondIdx = -1; - for (let i = firstIdx + needle.length; i <= buf.length - needle.length; i++) { - let match = true; - for (let j = 0; j < needle.length; j++) { - if (buf[i + j] !== needle[j]) { match = false; break; } - } - if (match) { secondIdx = i; break; } - } - if (secondIdx === -1) throw new Error('Could not parse OpenSSH private key block'); - - const offset = secondIdx + needle.length; - const pubLen = readUInt32BE(buf, offset); - const pubOffset = offset + 4; - const privLenOffset = pubOffset + pubLen; - const privLen = readUInt32BE(buf, privLenOffset); - const privOffset = privLenOffset + 4; - - if (privLen < 32 || privOffset + 32 > buf.length) { - throw new Error('Malformed OpenSSH private key length'); - } - - return buf.slice(privOffset, privOffset + 32); - } - - // Check if 64-char hex string (32 bytes) - if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { - return hexToBytes(trimmed); - } - - // Check if 44-char base64 string (32 bytes) - try { - const bytes = base64ToBytes(trimmed); - if (bytes.length === 32) return bytes; - if (bytes.length === 64) return bytes.slice(0, 32); - } catch (_) {} - - throw new Error('Unrecognized key format. Paste an OpenSSH private key (id_ed25519) or 32-byte hex/base64 seed.'); - } - - /** - * Import raw 32-byte seed into Web Crypto SubtleCrypto - */ - async importSeed(seedBytes) { - if (seedBytes.length !== 32) throw new Error('Seed must be exactly 32 bytes'); - const pkcs8 = concatUint8Arrays([PKCS8_HEADER, seedBytes]); - const cryptoKey = await window.crypto.subtle.importKey( - 'pkcs8', - pkcs8, - { name: 'Ed25519' }, - true, - ['sign'] - ); - - // Derive raw public key - // Since WebCrypto doesn't directly export public key from private key in all browsers, - // we can also generate or sign/verify or use the derived public key - // But in modern Chromium/Firefox/Safari, Web Crypto can export or derive. - // If needed, we can extract from OpenSSH PEM if available, or generate a sign verify test - let rawPub = null; - try { - // Many browsers support exporting JWK or SPKI - const spkiBuffer = await window.crypto.subtle.exportKey?.('spki', cryptoKey).catch(() => null); - if (spkiBuffer) { - rawPub = new Uint8Array(spkiBuffer).slice(12); - } - } catch (_) {} - - return { cryptoKey, rawSeed: seedBytes, rawPub }; - } - - /** - * Generate fresh Ed25519 identity in the browser - */ - async generateIdentity() { - const keyPair = await window.crypto.subtle.generateKey( - { name: 'Ed25519' }, - true, - ['sign', 'verify'] - ); - - const rawPubBuf = await window.crypto.subtle.exportKey('raw', keyPair.publicKey); - const rawPub = new Uint8Array(rawPubBuf); - - const pkcs8Buf = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey); - const pkcs8 = new Uint8Array(pkcs8Buf); - const rawSeed = pkcs8.slice(16); - - const pubkey = this.encodeOpenSSHPublicKey(rawPub); - const fingerprint = await this.computeFingerprint(rawPub); - - this.cryptoKey = keyPair.privateKey; - this.rawPub = rawPub; - this.rawSeed = rawSeed; - this.pubkey = pubkey; - this.fingerprint = fingerprint; - this.shortFingerprint = fingerprint.slice(7, 15); - - localStorage.setItem(STORAGE_KEY_PRIV, bytesToHex(rawSeed)); - localStorage.setItem(STORAGE_KEY_PUB, pubkey); - localStorage.setItem(STORAGE_KEY_FP, fingerprint); - - return this.getIdentity(); - } - - /** - * Import custom key from user text input - */ - async importKey(input) { - const seedBytes = this.extractSeedFromInput(input); - const pkcs8 = concatUint8Arrays([PKCS8_HEADER, seedBytes]); - const privateKey = await window.crypto.subtle.importKey( - 'pkcs8', - pkcs8, - { name: 'Ed25519' }, - true, - ['sign'] - ); - - // In Ed25519, the public key is deterministically derived from the seed - // To get the public key in Web Crypto, we sign a test challenge or compute it - // Let's create an identity check - this.cryptoKey = privateKey; - this.rawSeed = seedBytes; - - // Extract rawPub if input was OpenSSH PEM - let rawPub = null; - if (input.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) { - try { - const cleanB64 = input.replace(/-----[^\n]+-----/g, '').replace(/\s+/g, ''); - const buf = base64ToBytes(cleanB64); - const needle = new TextEncoder().encode('ssh-ed25519'); - let idx = -1; - for (let i = 0; i <= buf.length - needle.length; i++) { - let match = true; - for (let j = 0; j < needle.length; j++) { - if (buf[i + j] !== needle[j]) { match = false; break; } - } - if (match) { idx = i; break; } - } - if (idx !== -1) { - const pubLen = readUInt32BE(buf, idx + needle.length); - const pubBytes = buf.slice(idx + needle.length + 4, idx + needle.length + 4 + pubLen); - if (pubBytes.length === 32) rawPub = pubBytes; - } - } catch (_) {} - } - - if (!rawPub) { - // Fallback: generate keypair or re-derive - // If browser allows generateKey, generate identity and replace seed - rawPub = new Uint8Array(32); // fallback - } - - this.rawPub = rawPub; - this.pubkey = this.encodeOpenSSHPublicKey(rawPub); - this.fingerprint = await this.computeFingerprint(rawPub); - this.shortFingerprint = this.fingerprint.slice(7, 15); - - localStorage.setItem(STORAGE_KEY_PRIV, bytesToHex(seedBytes)); - localStorage.setItem(STORAGE_KEY_PUB, this.pubkey); - localStorage.setItem(STORAGE_KEY_FP, this.fingerprint); - - await this.ensureSession(true, isExplicitLogin); - return this.getIdentity(); - } - - getIdentity() { - return { - pubkey: this.pubkey, - fingerprint: this.fingerprint, - shortFingerprint: this.shortFingerprint, - rawPub: this.rawPub, - rawSeed: this.rawSeed - }; - } - - /** - * Sign an arbitrary UTF-8 string with the Ed25519 private key - */ - async sign(message) { - if (!this.cryptoKey) throw new Error('No active SSH private key'); - const msgBytes = new TextEncoder().encode(message); - const sigBuffer = await window.crypto.subtle.sign( - { name: 'Ed25519' }, - this.cryptoKey, - msgBytes - ); - return bytesToBase64(new Uint8Array(sigBuffer)); - } - - /** - * Authenticate to backend and establish/refresh anonymous session - */ - 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; - } - - if (this.isSessionReady && !force) return; - - try { - const timestamp = Date.now(); - const message = `anon-auth:${timestamp}:${this.pubkey}`; - const signature = await this.sign(message); - const tombstone = this.getTombstone(); - const hwFingerprint = await this.getHardwareFingerprint(); - - const res = await fetch('/api/v2/anon/session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - pubkey: this.pubkey, - timestamp: timestamp, - signature: signature, - tombstone: tombstone, - hw_fingerprint: hwFingerprint - }) - }); - - const data = await res.json(); - if (data.banned) { - this.setTombstone({ - banned: true, - fingerprint: data.fingerprint || this.fingerprint, - hw_fingerprint: data.hw_fingerprint || hwFingerprint, - reason: data.reason, - expires: data.expires - }); - 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; - } - - if (data.success) { - this.isSessionReady = true; - if (window.f0ckSession) { - window.f0ckSession.user = 'anonymous'; - window.f0ckSession.is_anon = true; - window.f0ckSession.logged_in = true; - window.f0ckSession.id = data.user_id; - window.f0ckSession.user_id = data.user_id; - if (data.csrf_token) window.f0ckSession.csrf_token = data.csrf_token; - } - const metaCsrf = document.querySelector('meta[name="csrf-token"]'); - if (metaCsrf && data.csrf_token) metaCsrf.content = data.csrf_token; - window.f0ckAnonIdentity = { - userId: data.user_id, - fingerprint: data.fingerprint, - shortFingerprint: data.short_fingerprint, - hwFingerprint: data.hw_fingerprint || hwFingerprint - }; - window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity })); - if (typeof window.syncRatingButtonUI === 'function') { - window.syncRatingButtonUI(); - } - } - } catch (err) { - console.warn('[ANON_SSH] Session handshake failed:', err); - } - } - - /** - * Get client ban tombstone if previously banned. - */ - getTombstone() { - try { - const raw = localStorage.getItem('f0ck_anon_tombstone'); - return raw ? JSON.parse(raw) : null; - } catch (e) { - return null; - } - } - - /** - * Save client ban tombstone. - */ - 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) {} - } - - /** - * Trigger browser download of id_ed25519 private key file - */ - downloadPrivateKey() { - if (!this.rawSeed || !this.rawPub) return; - const pem = this.encodeOpenSSHPrivateKey(this.rawSeed, this.rawPub); - const blob = new Blob([pem], { type: 'application/octet-stream' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'id_ed25519'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(a.href); - } - - /** - * Trigger browser download of id_ed25519.pub public key file - */ - downloadPublicKey() { - if (!this.pubkey) return; - const content = `${this.pubkey}\n`; - const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'id_ed25519.pub'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(a.href); - } - - /** - * Copy public key string to clipboard - */ - async copyPublicKey() { - if (!this.pubkey) return; - try { - await navigator.clipboard.writeText(this.pubkey); - if (typeof window.showToastNotification === 'function') { - window.showToastNotification('OpenSSH Public Key copied to clipboard!'); - } else { - alert('Public key copied to clipboard!'); - } - } catch (err) { - prompt('Copy your OpenSSH Public Key:', this.pubkey); - } - } - - /** - * Clear stored cryptographic identity from localStorage and memory - */ - clearStoredIdentity() { - 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; - this.pubkey = null; - this.fingerprint = null; - this.shortFingerprint = null; - this.isSessionReady = false; - } - - /** - * Explicitly log in as anonymous (generates identity if none, establishes session, syncs guest favs) - */ - async loginAsAnonymous() { - if (this._loginInProgress) return; 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'; + if (label) label.textContent = 'guest'; + ['nav-login-anon-btn', 'nav-anon-identity-btn', 'nav-anon-settings-btn', 'nav-anon-logout-btn', 'nav-anon-divider'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + const modalAnonBtn = document.getElementById('modal-login-as-anon-btn'); + if (modalAnonBtn) { + const w = modalAnonBtn.closest('div'); + if (w) w.style.display = 'none'; else modalAnonBtn.style.display = 'none'; } return; } + const loginAnonBtn = document.getElementById('nav-login-anon-btn'); + const anonIdentityBtn = document.getElementById('nav-anon-identity-btn'); + const anonSettingsBtn = document.getElementById('nav-anon-settings-btn'); + const anonLogoutBtn = document.getElementById('nav-anon-logout-btn'); + const anonDivider = document.getElementById('nav-anon-divider'); + const guestFavsNav = document.getElementById('nav-guest-favs'); + const guestFavsLink = document.getElementById('nav-guest-favs-link'); + + if (loginAnonBtn) loginAnonBtn.style.display = isAnon ? 'none' : ''; + if (anonIdentityBtn) anonIdentityBtn.style.display = isAnon ? '' : 'none'; + if (anonSettingsBtn) anonSettingsBtn.style.display = isAnon ? '' : 'none'; + if (anonLogoutBtn) anonLogoutBtn.style.display = isAnon ? '' : 'none'; + if (anonDivider) anonDivider.style.display = isAnon ? '' : 'none'; + if (guestFavsNav) guestFavsNav.style.display = isAnon ? '' : 'none'; + if (guestFavsLink) guestFavsLink.style.display = isAnon ? '' : 'none'; + + if (isAnon && window.f0ckSession) { + window.f0ckSession.user = window.f0ckSession.user || 'anonymous'; + window.f0ckSession.is_anon = true; + window.f0ckSession.logged_in = true; + } + if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI(); + } + + _applySessionData(data, hwFingerprint) { + this.isSessionReady = true; + if (window.f0ckSession) { + window.f0ckSession.user = 'anonymous'; + window.f0ckSession.is_anon = true; + window.f0ckSession.logged_in = true; + window.f0ckSession.id = data.user_id; + window.f0ckSession.user_id = data.user_id; + if (data.csrf_token) window.f0ckSession.csrf_token = data.csrf_token; + } + const metaCsrf = document.querySelector('meta[name="csrf-token"]'); + if (metaCsrf && data.csrf_token) metaCsrf.content = data.csrf_token; + window.f0ckAnonIdentity = { + userId: data.user_id, + fingerprint: data.fingerprint, + shortFingerprint: data.short_fingerprint, + hwFingerprint: data.hw_fingerprint || hwFingerprint, + credentialId: data.credential_id + }; + window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity })); + if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI(); + } + + // ── Register a new passkey (anonymous user) ──────────────────────────── + + async register() { + if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.'); + + // 1. Get registration options from server + const beginRes = await fetch('/api/v2/anon/passkey/register/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const beginData = await beginRes.json(); + if (!beginData.success) throw new Error(beginData.msg || 'Server error during registration setup'); + + const rawChallenge = beginData.options.challenge; + const opts = beginData.options; + + // 2. Decode options for the WebAuthn API + const pkOpts = { + rp: opts.rp, + user: { + id: b64urlToArr(opts.user.id), + name: opts.user.name, + displayName: opts.user.displayName + }, + challenge: b64urlToArr(rawChallenge), + pubKeyCredParams: opts.pubKeyCredParams, + timeout: opts.timeout || 60000, + excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })), + authenticatorSelection: opts.authenticatorSelection, + attestation: opts.attestation || 'none' + }; + + // 3. Browser passkey creation prompt + const cred = await navigator.credentials.create({ publicKey: pkOpts }); + + // 4. Send attestation to server + const hwFp = await getHardwareFingerprint(); + const tombstone = getTombstone(); + const finishRes = await fetch('/api/v2/anon/passkey/register/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: rawChallenge, + credentialId: arrToB64url(cred.rawId), + clientDataJSON: arrToB64url(cred.response.clientDataJSON), + attestationObject: arrToB64url(cred.response.attestationObject), + hw_fingerprint: hwFp, + tombstone: tombstone + }) + }); + const finishData = await finishRes.json(); + + if (finishData.banned) { + setTombstone({ + banned: true, + fingerprint: finishData.fingerprint, + hw_fingerprint: finishData.hw_fingerprint || hwFp, + reason: finishData.reason, + expires: finishData.expires + }); + if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned'; + throw new Error('Banned: ' + (finishData.reason || '')); + } + if (!finishData.success) throw new Error(finishData.msg || 'Registration failed'); + + // Mark this browser as having a registered passkey for this site + this._markLocalPasskey(); + return finishData; + } + + // ── Authenticate with an existing passkey (anonymous user) ──────────── + + async authenticate() { + if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.'); + + // 1. Get auth challenge from server + const beginRes = await fetch('/api/v2/anon/passkey/auth/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const beginData = await beginRes.json(); + if (!beginData.success) throw new Error(beginData.msg || 'Server error during authentication setup'); + + const rawChallenge = beginData.options.challenge; + const opts = beginData.options; + + // 2. Invoke browser passkey picker + const pkOpts = { + challenge: b64urlToArr(rawChallenge), + rpId: opts.rpId, + userVerification: opts.userVerification || 'preferred', + timeout: opts.timeout || 60000, + allowCredentials: (opts.allowCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })) + }; + + const assertion = await navigator.credentials.get({ publicKey: pkOpts }); + + // 3. Send assertion to server + const hwFp = await getHardwareFingerprint(); + const tombstone = getTombstone(); + const finishRes = await fetch('/api/v2/anon/passkey/auth/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: rawChallenge, + credentialId: arrToB64url(assertion.rawId), + clientDataJSON: arrToB64url(assertion.response.clientDataJSON), + authenticatorData: arrToB64url(assertion.response.authenticatorData), + signature: arrToB64url(assertion.response.signature), + hw_fingerprint: hwFp, + tombstone: tombstone + }) + }); + const finishData = await finishRes.json(); + + if (finishData.banned) { + setTombstone({ + banned: true, + fingerprint: finishData.fingerprint, + hw_fingerprint: finishData.hw_fingerprint || hwFp, + reason: finishData.reason, + expires: finishData.expires + }); + if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned'; + throw new Error('Banned: ' + (finishData.reason || '')); + } + if (!finishData.success) throw new Error(finishData.msg || 'Authentication failed'); + + return finishData; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + _hasLocalPasskey() { + try { return !!localStorage.getItem('f0ck_anon_has_passkey'); } catch (e) { return false; } + } + + _markLocalPasskey() { + try { localStorage.setItem('f0ck_anon_has_passkey', '1'); } catch (e) {} + } + + async _finishLogin(data) { + const hwFp = await getHardwareFingerprint(); + this._applySessionData(data, hwFp); + if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') { + await window.f0ckGuestFavs.importToAccount(); + } + this.updateNavUI(true); + // Close both modals + const sm = document.getElementById('anon-setup-modal'); if (sm) sm.style.display = 'none'; + const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none'; + if (typeof window.showToastNotification === 'function') { + window.showToastNotification('Logged in as anonymous'); + } + window.location.reload(); + } + + // ── Public: called by the setup modal's "Create my passkey" button ──────── + + async doRegister() { + if (this._loginInProgress) return; this._loginInProgress = true; - - // Close the login modal immediately for visual feedback - const loginModal = document.getElementById('login-modal'); - if (loginModal) loginModal.style.display = 'none'; - try { - if (!this.pubkey) { - await this.generateIdentity(); - } - await this.ensureSession(true, true); - - if (!this.isSessionReady) { - this._loginInProgress = false; - return; - } - - // Sync any guest favorites saved in localStorage to this anon account - if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') { - await window.f0ckGuestFavs.importToAccount(); - } - this.updateNavUI(true); - if (typeof window.showToastNotification === 'function') { - window.showToastNotification('Logged in as anonymous'); - } - // Reload page to reflect authenticated anonymous session across navbar, comments, settings - window.location.reload(); + const data = await this.register(); // throws on error/cancel + this._markLocalPasskey(); + await this._finishLogin(data); } catch (err) { this._loginInProgress = false; - console.error('[ANON_SSH] Login as anonymous failed:', err); - alert('Failed to log in as anonymous: ' + (err.message || err)); + throw err; // modal handles the error display } } - /** - * Log out of anonymous session, returning to clean guest state - */ + // ── Public: called by the setup modal's "Use my passkey" button ─────────── + + async doAuthenticate() { + if (this._loginInProgress) return; + this._loginInProgress = true; + try { + const data = await this.authenticate(); + await this._finishLogin(data); + } catch (err) { + this._loginInProgress = false; + throw err; + } + } + + // ── Public: opens the setup modal (new vs returning view) ───────────────── + + openSetupModal() { + if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) return; + const tombstone = getTombstone(); + if (tombstone && tombstone.banned) { + if (window.location.pathname !== '/banned') window.location.href = '/banned'; + return; + } + if (!this.supported) { + alert('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.'); + return; + } + + // Close the login modal first + const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none'; + + const hasPasskey = this._hasLocalPasskey(); + const newView = document.getElementById('anon-setup-new'); + const retView = document.getElementById('anon-setup-returning'); + if (newView) newView.style.display = hasPasskey ? 'none' : ''; + if (retView) retView.style.display = hasPasskey ? '' : 'none'; + + const modal = document.getElementById('anon-setup-modal'); + if (modal) modal.style.display = 'flex'; + } + + // ── Legacy: clicking "Login as anonymous" anywhere just opens the modal ─── + + loginAsAnonymous() { + this.openSetupModal(); + } + + // ── Logout ──────────────────────────────────────────────────────────────── + async logoutAnonymous() { try { - this.clearStoredIdentity(); await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); } finally { this.updateNavUI(false); @@ -831,81 +471,35 @@ } } - /** - * Update navigation bar elements based on anonymous authentication state - */ - updateNavUI(isAnon) { - const icon = document.getElementById('nav-visitor-icon'); - const label = document.getElementById('nav-anon-label'); - if (icon) { - if (isAnon) { - icon.classList.remove('fa-user'); - icon.classList.add('fa-user-secret'); - } else { - icon.classList.remove('fa-user-secret'); - icon.classList.add('fa-user'); - } - } - if (label) { - label.textContent = isAnon ? 'anonymous' : 'guest'; - } + // ── Identity modal (shown when already logged in as anon) ───────────────── - if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) { - if (label) label.textContent = 'guest'; - const loginAnonBtn = document.getElementById('nav-login-anon-btn'); - const modalAnonBtn = document.getElementById('modal-login-as-anon-btn'); - const anonIdentityBtn = document.getElementById('nav-anon-identity-btn'); - const anonSettingsBtn = document.getElementById('nav-anon-settings-btn'); - const anonLogoutBtn = document.getElementById('nav-anon-logout-btn'); - const anonDivider = document.getElementById('nav-anon-divider'); - - if (loginAnonBtn) loginAnonBtn.style.display = 'none'; - if (modalAnonBtn) { - const wrapper = modalAnonBtn.closest('div'); - if (wrapper) wrapper.style.display = 'none'; - else modalAnonBtn.style.display = 'none'; - } - if (anonIdentityBtn) anonIdentityBtn.style.display = 'none'; - if (anonSettingsBtn) anonSettingsBtn.style.display = 'none'; - if (anonLogoutBtn) anonLogoutBtn.style.display = 'none'; - if (anonDivider) anonDivider.style.display = 'none'; - return; - } - - const loginAnonBtn = document.getElementById('nav-login-anon-btn'); - const anonIdentityBtn = document.getElementById('nav-anon-identity-btn'); - const anonSettingsBtn = document.getElementById('nav-anon-settings-btn'); - const anonLogoutBtn = document.getElementById('nav-anon-logout-btn'); - const anonDivider = document.getElementById('nav-anon-divider'); - - if (loginAnonBtn) loginAnonBtn.style.display = isAnon ? 'none' : ''; - if (anonIdentityBtn) anonIdentityBtn.style.display = isAnon ? '' : 'none'; - if (anonSettingsBtn) anonSettingsBtn.style.display = isAnon ? '' : 'none'; - if (anonLogoutBtn) anonLogoutBtn.style.display = isAnon ? '' : 'none'; - if (anonDivider) anonDivider.style.display = isAnon ? '' : 'none'; - - const guestFavsNav = document.getElementById('nav-guest-favs'); - const guestFavsLink = document.getElementById('nav-guest-favs-link'); - if (guestFavsNav) guestFavsNav.style.display = isAnon ? '' : 'none'; - if (guestFavsLink) guestFavsLink.style.display = isAnon ? '' : 'none'; - - if (isAnon && window.f0ckSession) { - window.f0ckSession.user = window.f0ckSession.user || 'anonymous'; - window.f0ckSession.is_anon = true; - window.f0ckSession.logged_in = true; - } - if (typeof window.syncRatingButtonUI === 'function') { - window.syncRatingButtonUI(); - } + openModal() { + const modal = document.getElementById('anon-passkey-modal'); + if (!modal) return; + this._refreshModalContent(); + modal.style.display = 'flex'; } - /** - * Initialize on page startup - */ + closeModal() { + const modal = document.getElementById('anon-passkey-modal'); + if (modal) modal.style.display = 'none'; + } + + async _refreshModalContent() { + try { + const res = await fetch('/api/v2/anon/identity'); + const data = await res.json(); + const fpEl = document.getElementById('anon-pk-fp-display'); + const credEl = document.getElementById('anon-pk-cred-display'); + if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none'; + if (credEl) credEl.textContent = data.credential_id || '—'; + } catch (e) {} + } + + // ── Init ────────────────────────────────────────────────────────────────── + async init() { - if (window.location.pathname === '/banned') { - return; - } + if (window.location.pathname === '/banned') return; if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) { if (window.f0ckSession.is_anon && window.f0ckSession.logged_in) { @@ -917,64 +511,24 @@ return; } - // If registered user, do not override - if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) { - return; - } + // Registered user — nothing to do + if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) return; - // If client has a ban tombstone, do NOT attempt background session handshake on page load/reload! - const tombstone = this.getTombstone(); + // Check tombstone — if banned, don't auto-login + const tombstone = 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); - - if (storedPriv && storedPub && storedFp) { - try { - const rawSeed = hexToBytes(storedPriv); - const pkcs8 = concatUint8Arrays([PKCS8_HEADER, rawSeed]); - this.cryptoKey = await window.crypto.subtle.importKey( - 'pkcs8', - pkcs8, - { name: 'Ed25519' }, - true, - ['sign'] - ); - this.rawSeed = rawSeed; - this.pubkey = storedPub; - this.fingerprint = storedFp; - this.shortFingerprint = storedFp.slice(7, 15); - - // Extract rawPub from storedPub wire format - const parts = storedPub.trim().split(/\s+/); - const wirePub = base64ToBytes(parts[1]); - const typeLen = readUInt32BE(wirePub, 0); - this.rawPub = wirePub.slice(4 + typeLen + 4, 4 + typeLen + 4 + 32); - - // 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 && window.f0ckSession.logged_in) { - this.isSessionReady = true; - this.updateNavUI(true); - } else { - // 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); - this.clearStoredIdentity(); - this.updateNavUI(false); - } + // If backend session already shows is_anon, mark ready + if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) { + this.isSessionReady = true; + this.updateNavUI(true); } else { - // Clean guest state! Do NOT auto-generate or establish session! - if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) { - // Stale backend anon session cookie without corresponding local key: clear cookie + // Stale backend session without a local key no longer applies — just show guest + if (window.f0ckSession && window.f0ckSession.is_anon && !window.f0ckSession.logged_in) { await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); window.location.reload(); return; @@ -982,92 +536,137 @@ this.updateNavUI(false); } - // Hook identity modal triggers and action buttons this.attachUIListeners(); } attachUIListeners() { - // Direct click handler for the modal login-as-anonymous button + // Modal login-as-anonymous button const modalAnonBtn = document.getElementById('modal-login-as-anon-btn'); if (modalAnonBtn) { - modalAnonBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.loginAsAnonymous(); - }); + modalAnonBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); this.loginAsAnonymous(); }); } - // Global click handler for Login as anonymous, Identity modal, & Logout - document.addEventListener('click', (e) => { - const identityTarget = e.target.closest('#nav-anon-identity-btn'); - if (identityTarget) { - e.preventDefault(); - this.openModal(); - return; + document.addEventListener('click', e => { + if (e.target.closest('#nav-anon-identity-btn')) { + e.preventDefault(); this.openModal(); return; } - - const loginTarget = e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon'); - if (loginTarget) { - e.preventDefault(); - this.loginAsAnonymous(); - return; + if (e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon')) { + e.preventDefault(); this.loginAsAnonymous(); return; } - const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]'); - if (logoutTarget && ((window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) || this.pubkey)) { - e.preventDefault(); - this.logoutAnonymous(); - return; + if (logoutTarget && window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) { + e.preventDefault(); this.logoutAnonymous(); return; } }); const anonBtn = document.getElementById('nav-anon-identity-btn'); - if (anonBtn) { - anonBtn.addEventListener('click', (e) => { - e.preventDefault(); - this.openModal(); + if (anonBtn) anonBtn.addEventListener('click', e => { e.preventDefault(); this.openModal(); }); + + const modalClose = document.getElementById('anon-passkey-modal-close'); + if (modalClose) modalClose.addEventListener('click', () => this.closeModal()); + + const modalOverlay = document.getElementById('anon-passkey-modal'); + if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); }); + + // Add-passkey button inside modal (for anonymous users to add a second passkey) + const addBtn = document.getElementById('anon-pk-add-btn'); + if (addBtn) { + addBtn.addEventListener('click', async () => { + addBtn.disabled = true; + try { + await this.register(); + if (typeof window.showToastNotification === 'function') window.showToastNotification('New passkey registered!'); + this._refreshModalContent(); + } catch (err) { + if (err.name !== 'NotAllowedError') alert('Failed to add passkey: ' + err.message); + } finally { + addBtn.disabled = false; + } }); } - - const modalClose = document.getElementById('anon-ssh-modal-close'); - if (modalClose) { - modalClose.addEventListener('click', () => this.closeModal()); - } - - const modalOverlay = document.getElementById('anon-ssh-modal'); - if (modalOverlay) { - modalOverlay.addEventListener('click', (e) => { - if (e.target === modalOverlay) this.closeModal(); - }); - } - } - - openModal() { - const modal = document.getElementById('anon-ssh-modal'); - if (!modal) return; - - const fp = this.fingerprint || localStorage.getItem(STORAGE_KEY_FP); - const pk = this.pubkey || localStorage.getItem(STORAGE_KEY_PUB); - - const fpEl = document.getElementById('anon-ssh-fp-display'); - const pubEl = document.getElementById('anon-ssh-pub-display'); - if (fpEl) fpEl.textContent = fp || 'Generating...'; - if (pubEl) pubEl.textContent = pk || ''; - - modal.style.display = 'flex'; - } - - closeModal() { - const modal = document.getElementById('anon-ssh-modal'); - if (modal) modal.style.display = 'none'; } } - window.f0ckAnonSSH = new AnonSSH(); + // ─── Passkey manager for registered users (used by settings page) ────────── + + class PasskeyManager { + async listPasskeys() { + const res = await fetch('/api/v2/settings/passkeys'); + return (await res.json()).passkeys || []; + } + + async addPasskey(name) { + // 1. Begin + const beginRes = await fetch('/api/v2/settings/passkeys/register/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name || 'Passkey' }) + }); + const beginData = await beginRes.json(); + if (!beginData.success) throw new Error(beginData.msg || 'Server error'); + + const rawChallenge = beginData.options.challenge; + const opts = beginData.options; + + const pkOpts = { + rp: opts.rp, + user: { + id: b64urlToArr(opts.user.id), + name: opts.user.name, + displayName: opts.user.displayName + }, + challenge: b64urlToArr(rawChallenge), + pubKeyCredParams: opts.pubKeyCredParams, + timeout: opts.timeout || 60000, + excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })), + authenticatorSelection: opts.authenticatorSelection, + attestation: opts.attestation || 'none' + }; + + const cred = await navigator.credentials.create({ publicKey: pkOpts }); + + // 2. Finish + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + const finishRes = await fetch('/api/v2/settings/passkeys/register/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken }, + body: JSON.stringify({ + challenge: rawChallenge, + credentialId: arrToB64url(cred.rawId), + clientDataJSON: arrToB64url(cred.response.clientDataJSON), + attestationObject: arrToB64url(cred.response.attestationObject), + name: name || 'Passkey' + }) + }); + const finishData = await finishRes.json(); + if (!finishData.success) throw new Error(finishData.msg || 'Registration failed'); + return finishData; + } + + async deletePasskey(credentialId) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + const res = await fetch('/api/v2/settings/passkeys/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken }, + body: JSON.stringify({ credential_id: credentialId }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.msg || 'Delete failed'); + return data; + } + } + + // ─── Bootstrap ──────────────────────────────────────────────────────────── + + window.f0ckAnonPasskey = new AnonPasskey(); + window.f0ckPasskeyManager = new PasskeyManager(); + + // Backwards compat alias (so any code that checks window.f0ckAnonSSH still works for guards) + window.f0ckAnonSSH = window.f0ckAnonPasskey; if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => window.f0ckAnonSSH.init()); + document.addEventListener('DOMContentLoaded', () => window.f0ckAnonPasskey.init()); } else { - window.f0ckAnonSSH.init(); + window.f0ckAnonPasskey.init(); } })(); diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index b66acfb..a466e31 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -96,6 +96,8 @@ window.cancelAnimFrame = (function () { // 3. Fallback to URL pathname if on an item page const path = window.location.pathname; if (path.includes('/admin/') || path.includes('/mod/') || path.includes('/settings') || path.includes('/user/')) return null; + // Exclude pagination paths like /p/2, /tag/foo/p/3 — the trailing digit is a page number, not an item ID + if (/\/p\/\d+\/?$/.test(path)) return null; const match = path.match(/\/(\d+)\/?$/); if (match) return match[1]; @@ -207,7 +209,7 @@ window.cancelAnimFrame = (function () { console.log(`[RANDOM-POOL] nsfp_version changed (${window._randomPoolNsfpVersion} → ${newNsfpVersion}), invalidating pool`); } _shuffleArray(data.items); - window._randomPool = { items: data.items, total: data.total, sampled: data.sampled }; + window._randomPool = { items: data.items, total: data.total, sampled: data.sampled, idMap: data.id_map || {} }; window._randomPoolContext = contextKey; window._randomPoolCursor = 0; window._randomPoolNsfpVersion = newNsfpVersion; @@ -1894,7 +1896,24 @@ window.cancelAnimFrame = (function () { } }; - const updateOnaraActiveItem = (itemid, url, forceScroll = false, slug = null) => { + const getExtFromMime = (mime) => { + if (!mime || typeof mime !== 'string') return ''; + const sub = mime.split('/')[1] || mime; + return sub + .replace('youtube', 'yt') + .replace('x-shockwave-flash', 'flash') + .replace('vnd.adobe.flash.movie', 'flash') + .replace('x-zip-compressed', 'zip') + .replace('x-rar-compressed', 'rar') + .replace('vnd.rar', 'rar') + .replace('x-7z-compressed', '7z') + .replace('x-tar', 'tar') + .replace('x-bzip2', 'bz2') + .replace('x-xz', 'xz') + .toUpperCase(); + }; + + const updateOnaraActiveItem = (itemid, url, forceScroll = false, slug = null, extraMeta = null) => { if (!isOnaraActive()) return null; // Clear onara-active from any and all elements to guarantee only 1 item is selected document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); @@ -1912,6 +1931,12 @@ window.cancelAnimFrame = (function () { if (!targetThumb && itemid) { targetThumb = document.querySelector(`.posts > a.thumb[data-item-id="${itemid}"], .posts > a.thumb[href$="/${itemid}"], .posts > a.thumb[href*="/${itemid}#"], .posts > a.thumb[data-bg*="/${itemid}."]`); } + if (!targetThumb) { + const altNumericId = window._randomPool?.idMap?.[slug] || window._randomPool?.idMap?.[itemid]; + if (altNumericId) { + targetThumb = document.querySelector(`.posts > a.thumb[data-item-id="${altNumericId}"], .posts > a.thumb[href$="/${altNumericId}"], .posts > a.thumb[data-bg*="/${altNumericId}."]`); + } + } // Blur any other active element so browser native focus cannot highlight a second thumbnail if (document.activeElement && document.activeElement !== targetThumb && typeof document.activeElement.blur === 'function') { @@ -1920,25 +1945,180 @@ window.cancelAnimFrame = (function () { if (targetThumb) { targetThumb.classList.add('onara-active'); + if (extraMeta) { + if (extraMeta.mime && !targetThumb.dataset.mime) { + targetThumb.dataset.mime = extraMeta.mime; + targetThumb.dataset.ext = getExtFromMime(extraMeta.mime); + } + if (extraMeta.user && !targetThumb.dataset.user) { + targetThumb.dataset.user = extraMeta.user; + } + if (extraMeta.dest && !targetThumb.dataset.file) { + targetThumb.dataset.file = String(extraMeta.dest).replace(/^\/b\//, ''); + } + } scrollOnaraThumbIntoView(targetThumb, forceScroll); } return targetThumb; }; window.updateOnaraActiveItem = updateOnaraActiveItem; + const resolveItemThumbInfo = (itemid, slug, extraMeta = null) => { + let numericId = extraMeta?.numericId || null; + let thumb = extraMeta?.thumb || null; + let mode = extraMeta?.mode || null; + let mime = extraMeta?.mime || null; + let user = extraMeta?.user || null; + let dest = extraMeta?.dest || null; + + if (!numericId) { + if (typeof itemid === 'number' || (typeof itemid === 'string' && /^\d+$/.test(itemid))) { + numericId = itemid; + } else if (typeof slug === 'number' || (typeof slug === 'string' && /^\d+$/.test(slug))) { + numericId = slug; + } else if (window._randomPool?.idMap) { + if (slug && window._randomPool.idMap[slug]) { + numericId = window._randomPool.idMap[slug]; + } else if (itemid && window._randomPool.idMap[itemid]) { + numericId = window._randomPool.idMap[itemid]; + } + } + if (!numericId && typeof window.getCurrentItemId === 'function') { + const cur = window.getCurrentItemId(); + if (cur && /^\d+$/.test(cur)) numericId = cur; + } + } + + // Recover mime, user, dest from mounted Onara DOM if not passed in extraMeta + if (!mime) { + const mimeEl = document.querySelector('#onara-item-mount #info-file-mime, #onara-item-mount [data-mime], #info-file-mime'); + if (mimeEl) { + mime = mimeEl.getAttribute('data-mime') || mimeEl.textContent?.trim() || null; + } + if (!mime) { + const mediaEl = document.querySelector('#onara-item-mount video, #onara-item-mount audio, #onara-item-mount img'); + if (mediaEl) { + if (mediaEl.tagName === 'VIDEO') mime = 'video/mp4'; + else if (mediaEl.tagName === 'AUDIO') mime = 'audio/mp3'; + else if (mediaEl.tagName === 'IMG') { + const src = mediaEl.src || ''; + if (src.endsWith('.png')) mime = 'image/png'; + else if (src.endsWith('.gif')) mime = 'image/gif'; + else if (src.endsWith('.webp')) mime = 'image/webp'; + else mime = 'image/jpeg'; + } + } + } + } + + if (!user) { + const userEl = document.querySelector('#onara-item-mount #a_username, #a_username'); + if (userEl) { + user = userEl.getAttribute('data-username') || userEl.textContent?.trim() || null; + } + } + + if (!dest) { + const directLinkEl = document.querySelector('#onara-item-mount #info-file-direct-link, #info-file-direct-link'); + if (directLinkEl) { + dest = directLinkEl.getAttribute('href') || null; + } + } + + if (!thumb && numericId) { + thumb = `/t/${numericId}.webp`; + } else if (!thumb && typeof itemid === 'string' && /^\d+$/.test(itemid)) { + thumb = `/t/${itemid}.webp`; + } + + if (!mode) { + if (typeof window.activeMode === 'string' && ['sfw', 'nsfw', 'nsfl'].includes(window.activeMode)) { + mode = window.activeMode; + } else { + mode = 'sfw'; + } + } + + return { numericId, thumb, mode, mime, user, dest }; + }; + + const createSynthThumb = (itemid, url, slug, extraMeta = null) => { + const { numericId, thumb, mode, mime, user, dest } = resolveItemThumbInfo(itemid, slug, extraMeta); + const synthThumb = document.createElement('a'); + synthThumb.href = url || `/${slug || itemid}`; + synthThumb.className = 'thumb lazy-thumb onara-active onara-synth-thumb loaded'; + if (numericId) { + synthThumb.dataset.itemId = numericId; + } + if (mime) { + synthThumb.dataset.mime = mime; + synthThumb.dataset.ext = getExtFromMime(mime); + } + if (user) { + synthThumb.dataset.user = user; + } + if (dest) { + synthThumb.dataset.file = String(dest).replace(/^\/b\//, ''); + } + const finalThumbUrl = thumb || (numericId ? `/t/${numericId}.webp` : `/t/${itemid}.webp`); + synthThumb.dataset.bg = finalThumbUrl; + synthThumb.setAttribute('data-mode', mode || 'sfw'); + synthThumb.dataset.size = '1'; + synthThumb.style.setProperty('--thumb-bg', `url('${finalThumbUrl}')`); + synthThumb.innerHTML = '

'; + return synthThumb; + }; + let _onaraSyncSeq = 0; - const syncOnaraBackgroundGrid = async (itemid, url, knownPage = null, slug = null) => { + const syncOnaraBackgroundGrid = async (itemid, url, knownPage = null, slug = null, extraMeta = null) => { if (!isOnaraActive()) return; + const isRandom = document.cookie.includes('random_mode=1') || (url && url.includes('random=1')) || window.location.search.includes('random=1'); + // Fast path: if target item is already in current background DOM (and not a temporary placeholder), just highlight and ensure visibility - const existingThumb = updateOnaraActiveItem(itemid, url, false, slug); + const existingThumb = updateOnaraActiveItem(itemid, url, false, slug, extraMeta); if (existingThumb && !existingThumb.classList.contains('onara-synth-thumb')) { return; } if (existingThumb && existingThumb.classList.contains('onara-synth-thumb')) { + if (isRandom) { + if (extraMeta) { + if (extraMeta.mime && !existingThumb.dataset.mime) { + existingThumb.dataset.mime = extraMeta.mime; + existingThumb.dataset.ext = getExtFromMime(extraMeta.mime); + } + if (extraMeta.user && !existingThumb.dataset.user) { + existingThumb.dataset.user = extraMeta.user; + } + if (extraMeta.dest && !existingThumb.dataset.file) { + existingThumb.dataset.file = String(extraMeta.dest).replace(/^\/b\//, ''); + } + } + scrollOnaraThumbIntoView(existingThumb, false); + return; + } existingThumb.remove(); } + const currentPosts = document.querySelector('.posts'); + + // In random (ZOMG) mode, if posts already exist in background, don't fetch page 1 (which wipes the grid with random items). + // Instead, seamlessly insert the synthetic thumb at a random position in the existing grid. + if (isRandom && currentPosts && currentPosts.children.length > 0) { + currentPosts.querySelectorAll('.onara-synth-thumb').forEach(el => el.remove()); + const synthThumb = createSynthThumb(itemid, url, slug, extraMeta); + const children = Array.from(currentPosts.children); + const randIdx = Math.floor(Math.random() * (children.length + 1)); + if (randIdx < children.length) { + currentPosts.insertBefore(synthThumb, children[randIdx]); + } else { + currentPosts.appendChild(synthThumb); + } + if (typeof window.initLazyLoading === 'function') window.initLazyLoading(); + scrollOnaraThumbIntoView(synthThumb, true); + return; + } + const currentSeq = ++_onaraSyncSeq; const urlObj = new URL(url || window.location.href, window.location.origin); const feedBasePath = urlObj.pathname.replace(/\/+$/, '').replace(/\/(?:\d+|[a-zA-Z0-9_-]{11})$/, '') || '/'; @@ -2067,16 +2247,21 @@ window.cancelAnimFrame = (function () { } } - const thumb = updateOnaraActiveItem(itemid, url, true, slug); - const currentPosts = document.querySelector('.posts'); - if (!thumb && currentPosts) { - const synthThumb = document.createElement('a'); - synthThumb.href = url; - synthThumb.className = 'thumb lazy-thumb onara-active onara-synth-thumb'; - synthThumb.dataset.bg = `/t/${itemid}.webp`; - synthThumb.dataset.size = '1'; - synthThumb.innerHTML = '

'; - currentPosts.prepend(synthThumb); + const thumb = updateOnaraActiveItem(itemid, url, true, slug, extraMeta); + const currentPostsFallback = document.querySelector('.posts'); + if (!thumb && currentPostsFallback) { + const synthThumb = createSynthThumb(itemid, url, slug, extraMeta); + if (isRandom && currentPostsFallback.children.length > 0) { + const children = Array.from(currentPostsFallback.children); + const randIdx = Math.floor(Math.random() * (children.length + 1)); + if (randIdx < children.length) { + currentPostsFallback.insertBefore(synthThumb, children[randIdx]); + } else { + currentPostsFallback.appendChild(synthThumb); + } + } else { + currentPostsFallback.prepend(synthThumb); + } if (typeof window.initLazyLoading === 'function') window.initLazyLoading(); scrollOnaraThumbIntoView(synthThumb, true); } @@ -3130,7 +3315,11 @@ window.cancelAnimFrame = (function () { }; const initAlbumGallery = () => { - const container = document.querySelector('.album-gallery-container'); + const onaraMount = document.getElementById('onara-item-mount'); + const isOnara = (typeof isOnaraActive === 'function' && isOnaraActive()) || document.body.classList.contains('onara-modal-open'); + const container = (isOnara && onaraMount) + ? (onaraMount.querySelector('.album-gallery-container') || document.querySelector('.album-gallery-container')) + : document.querySelector('.album-gallery-container'); if (!container) { window._currentActiveAlbumGallery = null; return; @@ -3413,8 +3602,24 @@ window.cancelAnimFrame = (function () { if (isAutoplayAllowed()) { const playPromise = videoEl.play(); if (playPromise !== undefined) { - playPromise.catch(() => { - playerWrap.classList.add('v0ck_initial'); + playPromise.then(() => { + playerWrap.classList.remove('v0ck_initial'); + }).catch((err) => { + if (err.name === 'AbortError') { + const onCanPlay = () => { + videoEl.removeEventListener('canplay', onCanPlay); + if (videoEl.paused) { + videoEl.play().then(() => { + playerWrap.classList.remove('v0ck_initial'); + }).catch(() => { + playerWrap.classList.add('v0ck_initial'); + }); + } + }; + videoEl.addEventListener('canplay', onCanPlay, { once: true }); + } else { + playerWrap.classList.add('v0ck_initial'); + } }); } } else { @@ -3509,8 +3714,24 @@ window.cancelAnimFrame = (function () { if (isAutoplayAllowed()) { const playPromise = audioEl.play(); if (playPromise !== undefined) { - playPromise.catch(() => { - playerWrap.classList.add('v0ck_initial'); + playPromise.then(() => { + playerWrap.classList.remove('v0ck_initial'); + }).catch((err) => { + if (err.name === 'AbortError') { + const onCanPlay = () => { + audioEl.removeEventListener('canplay', onCanPlay); + if (audioEl.paused) { + audioEl.play().then(() => { + playerWrap.classList.remove('v0ck_initial'); + }).catch(() => { + playerWrap.classList.add('v0ck_initial'); + }); + } + }; + audioEl.addEventListener('canplay', onCanPlay, { once: true }); + } else { + playerWrap.classList.add('v0ck_initial'); + } }); } } else { @@ -4006,6 +4227,39 @@ window.cancelAnimFrame = (function () { updateInfoModal: updateInfoModal, updateAlbumTags: updateAlbumTags, getCurrentSubf0ck: () => albumData[currentIndex], + removeSubf0ck: (subIdentifier) => { + let idx = -1; + if (typeof subIdentifier === 'number' && subIdentifier >= 0 && subIdentifier < albumData.length) { + idx = subIdentifier; + } else if (subIdentifier !== undefined && subIdentifier !== null) { + idx = albumData.findIndex(s => s && (s.id == subIdentifier || s.slug == subIdentifier || s.subf0ck_id == subIdentifier)); + } else { + idx = currentIndex; + } + if (idx === -1) idx = currentIndex; + if (idx < 0 || idx >= albumData.length) return false; + + albumData.splice(idx, 1); + if (albumData.length <= 1) { + window.location.reload(); + return true; + } + + const thumbs = container.querySelectorAll('.album-thumb-item'); + if (thumbs[idx]) { + thumbs[idx].remove(); + } + const updatedThumbs = container.querySelectorAll('.album-thumb-item'); + updatedThumbs.forEach((th, newIdx) => { + th.setAttribute('data-index', newIdx); + }); + + if (totalCountEl) totalCountEl.textContent = albumData.length; + + if (currentIndex >= albumData.length) currentIndex = albumData.length - 1; + showImage(currentIndex, 'none', true); + return true; + }, isHovered: false }; window.albumGallery = window._currentActiveAlbumGallery; @@ -4037,7 +4291,15 @@ window.cancelAnimFrame = (function () { const setupMedia = () => { window._currentActiveAlbumGallery = null; window.albumGallery = null; - const elem = document.querySelector("#my-video") || document.querySelector("audio#my-video"); + const onaraMount = document.getElementById('onara-item-mount'); + const isOnara = (typeof isOnaraActive === 'function' && isOnaraActive()) || document.body.classList.contains('onara-modal-open'); + let elem = null; + if (isOnara && onaraMount) { + elem = onaraMount.querySelector("#my-video, audio#my-video"); + } + if (!elem) { + elem = document.querySelector("#main #my-video, #main audio#my-video") || document.querySelector("#my-video, audio#my-video"); + } if (elem) { video = new v0ck(elem); } else { @@ -12049,8 +12311,16 @@ window.cancelAnimFrame = (function () { onaraMount.innerHTML = ''; } _container = onaraMount; - updateOnaraActiveItem(itemid, url, false, _cachedItem.slug); - syncOnaraBackgroundGrid(itemid, url, _cachedItem.page, _cachedItem.slug); + const cachedExtraMeta = { + numericId: _cachedItem.numericId, + thumb: _cachedItem.thumb, + mode: _cachedItem.mode, + mime: _cachedItem.mime, + user: _cachedItem.user, + dest: _cachedItem.dest + }; + updateOnaraActiveItem(itemid, url, false, _cachedItem.slug, cachedExtraMeta); + syncOnaraBackgroundGrid(itemid, url, _cachedItem.page, _cachedItem.slug, cachedExtraMeta); } else { _container = document.querySelector('#main .container') || (document.getElementById('main')?.classList.contains('item-view') ? document.getElementById('main') : null); const _isStructuralPage = !!document.querySelector('.pagewrapper'); @@ -12214,6 +12484,8 @@ window.cancelAnimFrame = (function () { const tStart = performance.now(); let html, paginationHtml, responseSlug = null, responsePage = null; + let responseNumericId = null, responseThumb = null, responseMode = null; + let responseMime = null, responseUser = null, responseDest = null; // ── Pre-fetched data path (merged random + item load) ────────────── if (options.prefetchedData) { @@ -12228,6 +12500,12 @@ window.cancelAnimFrame = (function () { paginationHtml = data.pagination; responseSlug = data.slug || data.item?.slug || null; responsePage = data.page || null; + responseNumericId = data.numeric_id || data.id || data.item?.id || null; + responseThumb = data.thumb || data.item?.thumb || (responseNumericId ? `/t/${responseNumericId}.webp` : null); + responseMode = data.mode ?? data.tag_id ?? data.item?.tag_id ?? null; + responseMime = data.mime || data.item?.matching_sub_mime || data.item?.mime || null; + responseUser = data.user || data.username || data.item?.author_display_name || data.item?.display_name || data.item?.username || null; + responseDest = data.dest || data.file || data.item?.matching_sub_dest || data.item?.dest || null; } window.f0ckDebug(`[CLIENT_DEBUG] Using pre-fetched data (skipped network fetch)`); } else { @@ -12259,6 +12537,12 @@ window.cancelAnimFrame = (function () { paginationHtml = data.pagination; responseSlug = data.slug || data.item?.slug || null; responsePage = data.page || null; + responseNumericId = data.numeric_id || data.id || data.item?.id || null; + responseThumb = data.thumb || data.item?.thumb || (responseNumericId ? `/t/${responseNumericId}.webp` : null); + responseMode = data.mode ?? data.tag_id ?? data.item?.tag_id ?? null; + responseMime = data.mime || data.item?.matching_sub_mime || data.item?.mime || null; + responseUser = data.user || data.username || data.item?.author_display_name || data.item?.display_name || data.item?.username || null; + responseDest = data.dest || data.file || data.item?.matching_sub_dest || data.item?.dest || null; } else { html = rawText; } @@ -12270,7 +12554,18 @@ window.cancelAnimFrame = (function () { // ── Store in item cache (stale-while-revalidate) ─────────────────────── if (html && !options.noCacheStore) { - itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, page: responsePage, ts: Date.now() }); + itemCacheMap.set(_itemCacheKey, { + html, + slug: responseSlug, + page: responsePage, + numericId: responseNumericId, + thumb: responseThumb, + mode: responseMode, + mime: responseMime, + user: responseUser, + dest: responseDest, + ts: Date.now() + }); if (itemCacheMap.size > ITEM_CACHE_MAX) { // Evict oldest entry itemCacheMap.delete(itemCacheMap.keys().next().value); @@ -12290,8 +12585,16 @@ window.cancelAnimFrame = (function () { onaraMount.innerHTML = ''; } container = onaraMount; - updateOnaraActiveItem(itemid, url, false, responseSlug); - syncOnaraBackgroundGrid(itemid, url, responsePage, responseSlug); + const currentExtraMeta = { + numericId: responseNumericId, + thumb: responseThumb, + mode: responseMode, + mime: responseMime, + user: responseUser, + dest: responseDest + }; + updateOnaraActiveItem(itemid, url, false, responseSlug, currentExtraMeta); + syncOnaraBackgroundGrid(itemid, url, responsePage, responseSlug, currentExtraMeta); } else { container = document.querySelector('#main .container') || (document.getElementById('main') && document.getElementById('main').classList.contains('item-view') ? document.getElementById('main') : null); const isStructuralPage = !!document.querySelector('.pagewrapper'); @@ -12731,7 +13034,11 @@ window.cancelAnimFrame = (function () { // Background grid sync: pass targetUrl and null so actual page is looked up and synced if (isOnaraActive()) { - syncOnaraBackgroundGrid(pick, targetUrl, null, pick); + const numericId = window._randomPool?.idMap?.[pick] || (/^\d+$/.test(pick) ? pick : null); + syncOnaraBackgroundGrid(pick, targetUrl, null, pick, { + numericId, + thumb: numericId ? `/t/${numericId}.webp` : null + }); } return; } @@ -12802,7 +13109,14 @@ window.cancelAnimFrame = (function () { } loadItemAjax(targetUrl, true, { transition: 'fade-zoom', prefetchedData: data }); if (isOnaraActive() && data.id) { - syncOnaraBackgroundGrid(data.id, targetUrl, data.page || null, targetKey); + syncOnaraBackgroundGrid(data.id, targetUrl, data.page || null, targetKey, { + numericId: data.numeric_id || data.id, + thumb: data.thumb || `/t/${data.numeric_id || data.id}.webp`, + mode: data.mode ?? data.tag_id, + mime: data.mime, + user: data.user || data.username, + dest: data.dest + }); } } else { // No items found — restore UI, don't redirect @@ -16580,7 +16894,8 @@ class NotificationSystem { this.retryCount = 0; this.maxRetries = 20; // Increased retries this.pendingNotifIds = new Set(); // item IDs notified before thumbnail was in the grid - this.activeTab = 'user'; // 'user' or 'system' + const activeTabEl = this.dropdown ? this.dropdown.querySelector('.notif-tab.active') : null; + this.activeTab = activeTabEl ? activeTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user'); this._cachedUser = []; this._cachedSystem = []; @@ -16906,15 +17221,18 @@ class NotificationSystem { if (!delId) return; window.f0ckDebug(`[SSE] Item deleted: ${delId}`); - // Remove from main grid — a.thumb is the anchor, li is its parent card - const thumb = document.querySelector(`a.thumb[href$="/${delId}"], a.lazy-thumb[href$="/${delId}"]`); - if (thumb) { + // Remove from main grid — prefer data-item-id (works with slugs too), fall back to href match + const gridCards = document.querySelectorAll( + `a.thumb[data-item-id="${delId}"], a.lazy-thumb[data-item-id="${delId}"], ` + + `a.thumb[href$="/${delId}"], a.lazy-thumb[href$="/${delId}"]` + ); + gridCards.forEach(thumb => { const card = thumb.closest('li') || thumb; card.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; card.style.opacity = '0'; card.style.transform = 'scale(0.95)'; setTimeout(() => card.remove(), 300); - } + }); // If currently viewing this item, navigate to next item using soft AJAX nav const currentItemId = (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null) || window.currentItemId; @@ -17014,6 +17332,33 @@ class NotificationSystem { document.dispatchEvent(new CustomEvent('f0ck:global_chat_topic', { detail: data.data })); } else if (data.type === 'global_chat_presence') { document.dispatchEvent(new CustomEvent('f0ck:global_chat_presence', { detail: data.data })); + } else if (data.type === 'brand_image') { + window.f0ckDebug(`[SSE] Brand image update received:`, data.data?.url); + if (data.data?.url) { + const src = data.data.url; + // Update the randomizeLogo pool so clicking the brand always uses the current image + window.f0ckBrandImages = [src]; + const logos = document.querySelectorAll('#navbar-logo'); + if (logos.length > 0) { + logos.forEach(el => { el.src = src; el.style.display = ''; }); + } else { + // Logo element doesn't exist yet (no image was set before) — create it + document.querySelectorAll('a.navbar-brand').forEach(brandLink => { + const textNode = Array.from(brandLink.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + const img = document.createElement('img'); + img.id = 'navbar-logo'; + img.src = src; + img.alt = document.title; + img.style.cssText = 'max-height:40px;vertical-align:middle;max-width:180px;width:auto;'; + if (textNode) brandLink.insertBefore(img, textNode); + else brandLink.prepend(img); + }); + } + } else { + window.f0ckBrandImages = []; + document.querySelectorAll('#navbar-logo').forEach(el => el.remove()); + } + } } catch (err) { console.error('SSE data parse error', err); @@ -19113,6 +19458,9 @@ class ModAction { confirmBtn.innerText = options.confirmText || (hideReason ? (i18n.confirm_yes || 'Yes') : (i18n.confirm_btn || 'Confirm')); cancelBtn.innerText = options.cancelText || (hideReason ? (i18n.confirm_no || 'No') : (i18n.cancel_btn || 'Cancel')); + confirmBtn.style.display = options.hideConfirm ? 'none' : ''; + cancelBtn.style.display = options.hideCancel ? 'none' : ''; + const close = () => { modal.style.display = 'none'; cleanup(); @@ -19149,6 +19497,8 @@ class ModAction { const cleanup = () => { confirmBtn.onclick = null; cancelBtn.onclick = null; + confirmBtn.style.display = ''; + cancelBtn.style.display = ''; if (enterHandler) reasonEl.removeEventListener('keydown', enterHandler); confirmBtn.disabled = false; }; @@ -19813,6 +20163,11 @@ document.addEventListener('DOMContentLoaded', () => { } }; + // Helper to reset all category checkboxes + const resetReportCategories = () => { + document.querySelectorAll('.report-cat-check').forEach(cb => { cb.checked = false; }); + }; + // Open item report document.addEventListener('click', (e) => { const itemBtn = e.target.closest('.report-item-btn'); @@ -19822,6 +20177,7 @@ document.addEventListener('DOMContentLoaded', () => { reportCommentInput.value = ''; reportUserInput.value = ''; reportReason.value = ''; + resetReportCategories(); clearReportError(); reportModal.style.display = 'flex'; document.body.classList.add('modal-open'); @@ -19836,6 +20192,7 @@ document.addEventListener('DOMContentLoaded', () => { reportCommentInput.value = commentBtn.dataset.id; reportUserInput.value = ''; reportReason.value = ''; + resetReportCategories(); clearReportError(); reportModal.style.display = 'flex'; document.body.classList.add('modal-open'); @@ -19850,6 +20207,7 @@ document.addEventListener('DOMContentLoaded', () => { reportCommentInput.value = ''; reportUserInput.value = userBtn.dataset.userId; reportReason.value = ''; + resetReportCategories(); clearReportError(); reportModal.style.display = 'flex'; document.body.classList.add('modal-open'); @@ -19861,6 +20219,7 @@ document.addEventListener('DOMContentLoaded', () => { if (e.target.matches('#report-cancel') || e.target.id === 'report-modal') { reportModal.style.display = 'none'; document.body.classList.remove('modal-open'); + resetReportCategories(); clearReportError(); if (_reportRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_reportRcWidgetId); } catch(e) {} @@ -19871,8 +20230,10 @@ document.addEventListener('DOMContentLoaded', () => { if (e.target.matches('#report-submit')) { clearReportError(); const reason = reportReason.value.trim(); - if (!reason) { - showReportError((window.f0ckI18n && window.f0ckI18n.reason_required) || 'Please provide a reason.', reportReason); + const checkedCategories = Array.from(document.querySelectorAll('.report-cat-check:checked')).map(cb => cb.value); + + if (!reason && checkedCategories.length === 0) { + showReportError((window.f0ckI18n && window.f0ckI18n.reason_required) || 'Please select at least one reason or provide a description.', reportReason); return; } @@ -19894,7 +20255,8 @@ document.addEventListener('DOMContentLoaded', () => { if (reportItemInput.value) payload.append('item_id', reportItemInput.value); if (reportCommentInput.value) payload.append('comment_id', reportCommentInput.value); if (reportUserInput.value) payload.append('reported_user_id', reportUserInput.value); - payload.append('reason', reason); + if (reason) payload.append('reason', reason); + if (checkedCategories.length) payload.append('categories', checkedCategories.join(',')); if (rcToken) payload.append('g-recaptcha-response', rcToken); const submitBtn = e.target; @@ -19916,6 +20278,7 @@ document.addEventListener('DOMContentLoaded', () => { if (window.showFlash) window.showFlash((window.f0ckI18n && window.f0ckI18n.report_success) || 'Report submitted successfully.', 'success'); // Reset fields for future reports reportReason.value = ''; + resetReportCategories(); } else { const errMsg = data.msg || (window.f0ckI18n && window.f0ckI18n.report_error) || 'An error occurred.'; showReportError(errMsg); @@ -22312,6 +22675,9 @@ window.BulkSelection = (() => { const bar = document.createElement('div'); bar.id = 'bulk-action-bar'; bar.innerHTML = ` +
0 @@ -22344,13 +22710,14 @@ window.BulkSelection = (() => {
+ - `; document.body.appendChild(bar); @@ -22406,6 +22773,7 @@ window.BulkSelection = (() => { document.getElementById('bulk-btn-select-all')?.addEventListener('click', () => selectAllLoaded()); document.getElementById('bulk-btn-close')?.addEventListener('click', () => exitSelectionMode()); + document.getElementById('bulk-btn-delete')?.addEventListener('click', () => executeBulkDelete()); // Wire up Modal events document.getElementById('bulk-modal-close-btn')?.addEventListener('click', () => closeTagModal()); @@ -22779,6 +23147,59 @@ window.BulkSelection = (() => { } } + function executeBulkDelete() { + const itemIds = Array.from(selectedIds); + if (itemIds.length === 0) return; + + if (typeof ModAction === 'undefined') { + return window.flashMessage?.('Error: ModAction module not loaded', 3000, 'error'); + } + + const i18n = window.f0ckI18n || {}; + const title = i18n.item_delete_title || 'Delete Item'; + const msg = `Are you sure you want to delete ${itemIds.length} selected item(s)? This cannot be undone.`; + + ModAction.confirm(title, msg, async (reason) => { + let deleted = 0; + let failed = 0; + + for (const id of itemIds) { + try { + const res = await fetch('/api/v2/admin/deletepost', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify({ postid: id, reason: reason || 'Bulk delete' }) + }); + const data = await res.json(); + if (data.success) { + deleted++; + const thumb = document.querySelector(`a.thumb[data-item-id="${id}"], a.lazy-thumb[data-item-id="${id}"], a.thumb[href$="/${id}"], a.lazy-thumb[href$="/${id}"]`); + if (thumb) { + const card = thumb.closest('li') || thumb; + card.style.transition = 'opacity 0.25s ease, transform 0.25s ease'; + card.style.opacity = '0'; + card.style.transform = 'scale(0.92)'; + setTimeout(() => card.remove(), 260); + } + } else { + failed++; + console.warn(`[BULK_DELETE] Failed to delete item ${id}:`, data.msg); + } + } catch (err) { + failed++; + console.error(`[BULK_DELETE] Error deleting item ${id}:`, err); + } + } + + if (failed > 0) throw new Error(`Deleted ${deleted} item(s), ${failed} failed`); + flash(`Deleted ${deleted} item(s)`); + exitSelectionMode(); + }, { allowEmpty: window.f0ckSession?.is_admin, unsafeContent: true }); + } + // --- Event Listeners Initialization --- function init() { ensureDOMElements(); @@ -22979,6 +23400,7 @@ window.BulkSelection = (() => { openTagModal, closeTagModal, executeBulkRating, + executeBulkDelete, selectAllLoaded, getSelectedIds: () => Array.from(selectedIds) }; diff --git a/public/s/js/mod-reports.js b/public/s/js/mod-reports.js new file mode 100644 index 0000000..67bb1f2 --- /dev/null +++ b/public/s/js/mod-reports.js @@ -0,0 +1,294 @@ + +var CATEGORY_META = { + wrong_rating: { label: 'Wrong Rating', bg: '#ffc107', color: '#000' }, + spam: { label: 'Spam', bg: '#6c757d', color: '#fff' }, + duplicate: { label: 'Duplicate', bg: '#17a2b8', color: '#fff' }, + copyright: { label: 'Copyright', bg: '#fd7e14', color: '#fff' }, + illegal: { label: 'Illegal', bg: '#dc3545', color: '#fff' }, + other: { label: 'Other', bg: '#495057', color: '#fff' } +}; + +function catBadge(c) { + var m = CATEGORY_META[c] || { label: c, bg: '#444', color: '#fff' }; + return '' + m.label + ''; +} + +function rpEsc(s) { + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function relTime(d) { + var diff = (Date.now() - new Date(d)) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff/60) + 'm ago'; + if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; + return new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +window.currentPage = window.currentPage || 1; + +window.loadReports = async function(page) { + page = page || 1; + window.currentPage = page; + var status = document.getElementById('report-status-filter').value; + var feed = document.getElementById('reports-feed'); + var pag = document.getElementById('reports-pagination'); + + feed.innerHTML = '
Loading...
'; + pag.innerHTML = ''; + + try { + var res = await fetch('/api/v2/mod/reports?status=' + status + '&page=' + page); + var data = await res.json(); + + if (!data.success) { + feed.innerHTML = '
Error: ' + rpEsc(data.msg) + '
'; + return; + } + + window.currentReports = data.reports; + window.emojiMap = new Map(); + if (data.emojis) data.emojis.forEach(function(e) { window.emojiMap.set(e.name.toLowerCase(), e.url); }); + + if (!data.reports.length) { + feed.innerHTML = '
No reports found.
'; + return; + } + + feed.innerHTML = ''; + var isAdmin = window.f0ckSession && window.f0ckSession.admin; + + data.reports.forEach(function(r) { + var card = document.createElement('div'); + var cats = Array.isArray(r.categories) ? r.categories : []; + card.className = 'rp-card' + (cats.indexOf('illegal') !== -1 ? ' illegal-flag' : ''); + + var statusBadge = '' + status + ''; + + var reporter = r.reporter_name + ? '' + rpEsc(r.reporter_name) + '' + (r.reporter_ip ? ' (' + rpEsc(r.reporter_ip) + ')' : '') + : 'Guest' + (r.reporter_ip ? ' (' + rpEsc(r.reporter_ip) + ')' : '') + ''; + + var targetHtml = ''; + var itemLink = ''; + if (r.comment_id) { + targetHtml = 'comment #' + r.comment_id + ''; + if (r.resolved_item_id) itemLink = ' item #' + r.resolved_item_id + ''; + } else if (r.resolved_item_id) { + targetHtml = 'item'; + itemLink = ' #' + r.resolved_item_id + ''; + } else if (r.reported_user_name) { + targetHtml = 'user ' + rpEsc(r.reported_user_name) + ''; + } + + var previewHtml = ''; + var isItem = !!r.resolved_item_id && r.resolved_item_dest; + var isComment = !!r.comment_id; + if (isItem && !isComment) { + var mime = r.resolved_item_mime || ''; + var src = '/b/' + r.resolved_item_dest; + var href = '/' + r.resolved_item_id; + if (mime === 'video/youtube') { + var ytId = r.resolved_item_dest.replace('yt:', ''); + previewHtml = '
'; + } else if (mime.indexOf('image/') === 0) { + previewHtml = '
'; + } else if (mime.indexOf('video/') === 0) { + previewHtml = '
'; + } else if (mime.indexOf('audio/') === 0) { + previewHtml = '
'; + } else { + previewHtml = '
'; + } + } else if (isComment) { + var body = (r.comment_body || '[deleted]').replace(/&/g,'&').replace(//g,'>'); + if (window.emojiMap) { + body = body.replace(/:([a-z0-9_]+):/g, function(match, code) { + var url = window.emojiMap.get(code.toLowerCase()); + return url ? '' : match; + }); + } + previewHtml = '
' + body + '
'; + } else { + previewHtml = '
'; + } + + var catsHtml = cats.length ? '
' + cats.map(catBadge).join('') + '
' : ''; + var reasonHtml = r.reason ? '
' + rpEsc(r.reason) + '
' : ''; + + var actions = ''; + if (status === 'pending') { + actions += ''; + actions += ''; + actions += ''; + } + if (isItem && !isComment) { + var isUnav = r.resolved_item_visibility === 3; + actions += ''; + actions += ''; + } + if (isComment) { + actions += ''; + } + if (r.reported_user_id && (isAdmin || !r.reported_user_is_admin)) { + var who = r.reported_user_name ? rpEsc(r.reported_user_name) : 'user'; + actions += ''; + actions += ''; + actions += ''; + } else if (!r.reported_user_id) { + actions += 'Anonymous reporter'; + } + if (r.reporter_id && r.reporter_name) { + actions += ''; + } + + var ts = new Date(r.created_at).toLocaleString(); + var rt = relTime(r.created_at); + + var resolverHtml = (status !== 'pending' && r.resolver_name) + ? 'by ' + rpEsc(r.resolver_name) + '' + : ''; + + card.innerHTML = + '
' + + '
' + + '#' + r.id + '' + + statusBadge + + resolverHtml + + 'from ' + reporter + '' + + (targetHtml ? '→ ' + targetHtml + '' : '') + + itemLink + + '
' + + '' + rt + '' + + '
' + + '
' + + previewHtml + + '
' + catsHtml + reasonHtml + '
' + + '
' + + '
' + actions + '
'; + + feed.appendChild(card); + }); + + pag.innerHTML = ''; + if (data.pages > 1) { + if (data.page > 1) + pag.innerHTML += ''; + pag.innerHTML += 'Page ' + data.page + ' of ' + data.pages + ''; + if (data.page < data.pages) + pag.innerHTML += ''; + } + + } catch(e) { + feed.innerHTML = '
Network error
'; + } +}; + +window.resolveReport = function(id, action) { + var label = action === 'resolved' ? 'Resolve' : 'Reject'; + var desc = action === 'resolved' + ? 'Mark report #' + id + ' as resolved.' + : 'Reject report #' + id + '. The content will remain as-is.'; + window.ModAction.confirm(label + ' Report #' + id, desc, async function() { + var params = new URLSearchParams(); + params.append('action', action); + var csrfToken = window.f0ckSession && window.f0ckSession.csrf_token; + var res = await fetch('/api/v2/mod/reports/' + id + '/resolve', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': csrfToken + }, + body: params + }); + var data = await res.json(); + if (data.success) { + window.loadReports(window.currentPage); + if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') + window.NotificationSystemInstance.pollDebounced(); + } else { + throw new Error(data.msg || 'Failed to update report'); + } + }, { hideReason: true }); +}; + +window.adminDeleteComment = function(id) { + window.ModAction.confirm('Delete Comment #' + id, 'Are you sure you want to delete this comment? This action is permanent.', async (reason) => { + var params = new URLSearchParams(); + params.append('reason', reason); + var res = await fetch('/api/comments/' + id + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }); + var data = await res.json(); + if (data.success) { if (window.showFlash) window.showFlash('comment deleted', 'success'); } + else throw new Error(data.msg || 'Unknown error'); + }); +}; + +window.adminDeleteItem = function(id) { + window.ModAction.confirm('Delete Item #' + id, 'Are you sure you want to delete this item? This action is permanent.', async (reason) => { + var params = new URLSearchParams(); + params.append('postid', id); params.append('reason', reason); + var res = await fetch('/api/v2/admin/deletepost', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }); + var data = await res.json(); + if (data.success) { if (window.showFlash) window.showFlash('item deleted', 'success'); } + else throw new Error(data.msg || 'Unknown error'); + }); +}; + +window.modToggleUnavailable = function(id, currentVis) { + var willBeUnavailable = currentVis !== 3; + var targetVis = willBeUnavailable ? 3 : 0; + var actionText = willBeUnavailable ? 'Make Unavailable (HTTP 451)' : 'Make Available (Public)'; + window.ModAction.confirm('Item Visibility', actionText + ' for item #' + id + '?', async () => { + var params = new URLSearchParams(); + params.append('postid', id); params.append('id', id); params.append('visibility', targetVis); + var csrfToken = window.f0ckSession && window.f0ckSession.csrf_token; + var res = await fetch('/api/v2/item/visibility', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken }, body: params }); + var data = await res.json(); + if (data.success) { + if (window.showFlash) window.showFlash(willBeUnavailable ? 'Item marked unavailable (451)' : 'Item restored to public', 'success'); + var item = window.currentReports.find(function(x) { return x.resolved_item_id === id; }); + if (item) item.resolved_item_visibility = targetVis; + window.loadReports(window.currentPage); + } else throw new Error(data.msg || 'Failed to update visibility'); + }); +}; + +window.modWarnUser = function(userId) { + window.ModAction.confirm('Warn User ID ' + userId, '', async (reason) => { + var params = new URLSearchParams(); + params.append('user_id', userId); params.append('reason', reason); + var res = await fetch('/api/v2/mod/warnings/issue', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }); + var data = await res.json(); + if (data.success) { if (window.showFlash) window.showFlash('user has been warned', 'success'); } + else throw new Error(data.msg || 'Unknown error'); + }); +}; + +window.adminBanUser = function(userId) { + var isAdmin = window.f0ckSession && window.f0ckSession.admin; + var promptHtml = + '

This will restrict the user from accessing their account and performing most actions.

' + + '
' + + '
'; + window.ModAction.confirm('Ban User ID ' + userId, promptHtml, async (reason) => { + var duration = document.getElementById('ban-duration-select').value; + var params = new URLSearchParams(); + params.append('user_id', userId); params.append('reason', reason); params.append('duration', duration); + var res = await fetch('/api/v2/admin/ban', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }); + var data = await res.json(); + if (data.success) { if (window.showFlash) window.showFlash('User banned cleanly.', 'success'); } + else throw new Error(data.msg || 'Unknown error'); + }); +}; + +(function() { + var filter = document.getElementById('report-status-filter'); + if (filter) filter.onchange = function() { window.loadReports(1); }; + window.loadReports(1); +})(); diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index 6eaec10..07a3306 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -3654,8 +3654,8 @@ // Tab type arrays const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment']; - const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning']; - let sActiveTab = 'user'; + const sActiveTabEl = sNotifDropdown ? sNotifDropdown.querySelector('.notif-tab.active') : null; + let sActiveTab = sActiveTabEl ? sActiveTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user'); let sCachedNotifs = []; if (sNotifBtn && sNotifDropdown) { diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index 87c681a..bc70f51 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -1170,7 +1170,7 @@ const renderVideoCard = (video, options = {}) => { const videoKey = (window.f0ckSession?.enable_item_slugs !== false && video.slug) ? video.slug : video.id; - const rClass = video.rating_class || 'untagged'; + const rClass = video.rating_class || (video.tag_id == 1 ? 'sfw' : (video.tag_id == 2 ? 'nsfw' : ((video.tag_id == 3 || video.tag_id == window.f0ckSession?.nsfl_tag_id) ? 'nsfl' : 'untagged'))); const blurNsfw = localStorage.getItem('blurNsfw') === 'true'; const blurNsfl = localStorage.getItem('blurNsfl') === 'true'; const blurSfw = localStorage.getItem('blurSfw') === 'true'; diff --git a/public/s/js/v0ck.js b/public/s/js/v0ck.js index d07aae8..d0229c6 100644 --- a/public/s/js/v0ck.js +++ b/public/s/js/v0ck.js @@ -815,13 +815,33 @@ class v0ck { // Attempt autoplay and show overlay if blocked const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true; if (shouldAutoplay) { - const playPromise = togglePlay(); - if (playPromise !== undefined) { - playPromise.catch(() => { + if (!video.paused) { + player.classList.remove('v0ck_initial'); + } else { + const playPromise = video.play(); + if (playPromise !== undefined) { + playPromise.then(() => { + player.classList.remove('v0ck_initial'); + }).catch((err) => { + if (err && err.name === 'AbortError') { + const onCanPlay = () => { + video.removeEventListener('canplay', onCanPlay); + if (video.paused) { + video.play().then(() => { + player.classList.remove('v0ck_initial'); + }).catch(() => { + player.classList.add('v0ck_initial'); + }); + } + }; + video.addEventListener('canplay', onCanPlay, { once: true }); + } else { + player.classList.add('v0ck_initial'); + } + }); + } else if (video.paused) { player.classList.add('v0ck_initial'); - }); - } else if (video.paused) { - player.classList.add('v0ck_initial'); + } } } else { player.classList.add('v0ck_initial'); diff --git a/src/brand_image_handler.mjs b/src/brand_image_handler.mjs new file mode 100644 index 0000000..6eacc48 --- /dev/null +++ b/src/brand_image_handler.mjs @@ -0,0 +1,201 @@ +import cfg from "./inc/config.mjs"; +import path from "path"; +import { promises as fs } from "fs"; +import db from "./inc/sql.mjs"; +import lib from "./inc/lib.mjs"; +import { parseMultipart, collectBody } from "./inc/multipart.mjs"; +import { execFile as _execFile } from "child_process"; +import { promisify } from "util"; +import audit from "./inc/audit.mjs"; +import { getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs"; + +const execFile = promisify(_execFile); + +const sendJson = (res, data, code = 200) => { + const body = JSON.stringify(data); + res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); +}; + +/** Shared admin session + CSRF lookup */ +async function getAdminSession(req, res) { + if (!req.cookies || !req.cookies.session) { + sendJson(res, { success: false, msg: 'Unauthorized' }, 401); + return null; + } + + const user = await db` + SELECT "user".id, "user".login, "user".user, "user".admin, + "user_sessions".id AS sess_id, "user_sessions".csrf_token + FROM "user_sessions" + LEFT JOIN "user" ON "user".id = "user_sessions".user_id + WHERE "user_sessions".session = ${lib.sha256(req.cookies.session)} + LIMIT 1 + `; + + if (user.length === 0 || !user[0].admin) { + sendJson(res, { success: false, msg: 'Unauthorized' }, 401); + return null; + } + + const session = user[0]; + + // CSRF validation via header + if (session.csrf_token) { + const csrfToken = req.headers['x-csrf-token']; + if (!csrfToken || csrfToken !== session.csrf_token) { + console.warn(`[CSRF] Blocked brand image request for user ${session.user}. Invalid token.`); + sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403); + return null; + } + } + + return session; +} + +/** Delete the physical file for a stored brand image URL (if any) */ +async function deleteOldBrandFile() { + const current = getBrandImageUrl(); + if (!current) return; + // Strip query string to get the bare filename + const urlPath = current.split('?')[0]; + // Only delete files that live under our navbar img dir + if (!urlPath.startsWith('/s/img/navbar/brand.')) return; + const filename = path.basename(urlPath); + const filePath = path.join(cfg.paths.s, 'img', 'navbar', filename); + await fs.unlink(filePath).catch(() => {}); +} + +/** Persist brand image URL to site_settings DB and in-memory setting */ +async function persistBrandImage(url) { + setBrandImageUrl(url); + await db` + INSERT INTO site_settings (key, value) + VALUES ('brand_image_url', ${url}) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `; +} + +// ── Upload ───────────────────────────────────────────────────────────────── +export const handleBrandImageUpload = async (req, res) => { + console.log('[BRAND UPLOAD] Started'); + + const session = await getAdminSession(req, res); + if (!session) return; + + try { + const contentType = req.headers['content-type'] || ''; + const boundaryMatch = contentType.match(/boundary=(.+)$/); + if (!boundaryMatch) { + return sendJson(res, { success: false, msg: 'Invalid content type — multipart boundary missing' }, 400); + } + + const body = await collectBody(req, 5 * 1024 * 1024); // 5 MB hard cap + const parts = parseMultipart(body, boundaryMatch[1]); + const file = parts.file; + + if (!file || !file.data || file.data.length === 0) { + return sendJson(res, { success: false, msg: 'No file provided' }, 400); + } + + // 2 MB soft cap + const maxSize = 2 * 1024 * 1024; + if (file.data.length > maxSize) { + return sendJson(res, { + success: false, + msg: `File too large. Maximum is 2 MB, got ${(file.data.length / 1024 / 1024).toFixed(2)} MB` + }, 400); + } + + const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/svg+xml']; + const mime = (file.contentType || '').toLowerCase().split(';')[0].trim(); + if (!allowedMimes.includes(mime)) { + return sendJson(res, { + success: false, + msg: `Invalid file type. Allowed: gif, jpg, png, webp, svg. Got: ${mime}` + }, 400); + } + + const imgDir = path.join(cfg.paths.s, 'img', 'navbar'); + await fs.mkdir(imgDir, { recursive: true }); + await fs.mkdir(cfg.paths.tmp, { recursive: true }); + + const isSvg = mime === 'image/svg+xml'; + const ts = Date.now(); + // Timestamp baked into the filename — every upload is a unique file + const outFilename = isSvg ? `brand.${ts}.svg` : `brand.${ts}.webp`; + const tmpPath = path.join(cfg.paths.tmp, `brand_tmp_${ts}`); + const finalPath = path.join(imgDir, outFilename); + + await fs.writeFile(tmpPath, file.data); + + if (!isSvg) { + // Verify actual MIME with file(1) + try { + const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]); + const safeActual = ['image/gif', 'image/jpeg', 'image/png', 'image/webp']; + if (!safeActual.includes(actualMime.trim())) { + await fs.unlink(tmpPath).catch(() => {}); + return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime.trim()}` }, 400); + } + } catch (_) { + // file(1) not available — skip magic check + } + + // Convert to WebP via ImageMagick + try { + await execFile('magick', [tmpPath, '-coalesce', '-quality', '85', finalPath]); + } catch (err) { + console.error('[BRAND UPLOAD] ImageMagick error:', err); + await fs.unlink(tmpPath).catch(() => {}); + return sendJson(res, { success: false, msg: 'Failed to process image (ImageMagick required)' }, 500); + } + } else { + // SVG: copy as-is + await fs.copyFile(tmpPath, finalPath); + } + + await fs.unlink(tmpPath).catch(() => {}); + + // Delete the previous brand file from disk + await deleteOldBrandFile(); + + const publicUrl = `/s/img/navbar/${outFilename}`; + + await persistBrandImage(publicUrl); + await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: publicUrl })})`.catch(() => {}); + await audit.log(session.id, 'update_brand_image', 'system', 0, { url: publicUrl }); + + console.log('[BRAND UPLOAD] Done:', publicUrl); + return sendJson(res, { success: true, url: publicUrl, msg: 'Brand image updated' }); + + } catch (err) { + if (err.code === 'BODY_TOO_LARGE') { + return sendJson(res, { success: false, msg: 'File too large (5 MB max)' }, 413); + } + console.error('[BRAND UPLOAD ERROR]', err); + return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500); + } +}; + +// ── Delete ───────────────────────────────────────────────────────────────── +export const handleBrandImageDelete = async (req, res) => { + console.log('[BRAND DELETE] Started'); + + const session = await getAdminSession(req, res); + if (!session) return; + + try { + // Delete the physical file before clearing the setting + await deleteOldBrandFile(); + + await persistBrandImage(''); + await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: null })})`.catch(() => {}); + await audit.log(session.id, 'delete_brand_image', 'system', 0, {}); + + console.log('[BRAND DELETE] Done'); + return sendJson(res, { success: true, msg: 'Brand image removed' }); + } catch (err) { + console.error('[BRAND DELETE ERROR]', err); + return sendJson(res, { success: false, msg: 'Delete failed: ' + err.message }, 500); + } +}; diff --git a/src/inc/anon_auth.mjs b/src/inc/anon_auth.mjs index 0d2f1e6..088f807 100644 --- a/src/inc/anon_auth.mjs +++ b/src/inc/anon_auth.mjs @@ -3,89 +3,6 @@ import db from './sql.mjs'; import lib from './lib.mjs'; import cfg from './config.mjs'; -const SPKI_ED25519_HEADER = Buffer.from('302a300506032b6570032100', 'hex'); - -/** - * Parse an OpenSSH formatted Ed25519 public key. - * Format: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... [comment]" - * @param {string} sshKey - * @returns {{ keyObject: crypto.KeyObject, rawPub: Buffer, wirePub: Buffer, fingerprint: string, shortFingerprint: string }} - */ -export function parseOpenSshPubkey(sshKey) { - if (!sshKey || typeof sshKey !== 'string') { - throw new Error('Missing or invalid SSH public key'); - } - - const parts = sshKey.trim().split(/\s+/); - if (parts.length < 2 || parts[0] !== 'ssh-ed25519') { - throw new Error('Only ssh-ed25519 keys are supported'); - } - - const wirePub = Buffer.from(parts[1], 'base64'); - if (wirePub.length < 19) { - throw new Error('Invalid OpenSSH public key wire payload'); - } - - const typeLen = wirePub.readUInt32BE(0); - if (typeLen !== 11) { - throw new Error('Invalid key type length in OpenSSH wire format'); - } - - const type = wirePub.subarray(4, 4 + typeLen).toString('utf8'); - if (type !== 'ssh-ed25519') { - throw new Error(`Expected ssh-ed25519, got ${type}`); - } - - const keyLenOffset = 4 + typeLen; - const keyLen = wirePub.readUInt32BE(keyLenOffset); - if (keyLen !== 32) { - throw new Error(`Invalid Ed25519 key length: expected 32, got ${keyLen}`); - } - - const rawPub = wirePub.subarray(keyLenOffset + 4, keyLenOffset + 4 + keyLen); - if (rawPub.length !== 32) { - throw new Error('Malformed Ed25519 raw public key'); - } - - // Construct standard SPKI DER for crypto.createPublicKey - const der = Buffer.concat([SPKI_ED25519_HEADER, rawPub]); - const keyObject = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' }); - - // Standard OpenSSH SHA256 fingerprint: SHA256: - const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(wirePub).digest('base64').replace(/=+$/, ''); - const shortFingerprint = fingerprint.slice(7, 15); - - return { keyObject, rawPub, wirePub, fingerprint, shortFingerprint }; -} - -/** - * Verify an Ed25519 signature against an OpenSSH public key. - * @param {string} sshPubkey - * @param {string|Buffer} message - * @param {string} signature (hex or base64) - * @returns {boolean} - */ -export function verifySignature(sshPubkey, message, signature) { - try { - const { keyObject } = parseOpenSshPubkey(sshPubkey); - const msgBuf = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8'); - - let sigBuf; - if (typeof signature === 'string') { - const isHex = /^[0-9a-fA-F]{128}$/.test(signature); - sigBuf = isHex ? Buffer.from(signature, 'hex') : Buffer.from(signature, 'base64'); - } else if (Buffer.isBuffer(signature)) { - sigBuf = signature; - } else { - return false; - } - - return crypto.verify(null, msgBuf, keyObject, sigBuf); - } catch (err) { - return false; - } -} - import security from './security.mjs'; import { getHashUserIps } from './settings.mjs'; @@ -127,35 +44,40 @@ export async function logAnonActivity(req, { action, targetId = null, details = } /** - * Find or create a shadow user in the database for an anonymous SSH identity. - * @param {string} pubkey - * @param {string} fingerprint + * Find or create a shadow user in the database for a passkey-authenticated anonymous identity. + * + * @param {string} credentialId - base64url WebAuthn credential ID * @param {object} [req] * @param {string} [hwFingerprint] - * @returns {Promise<{ userId: number, isNew: boolean }>} + * @returns {Promise<{ userId: number, isNew: boolean, fingerprint: string }>} */ -export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFingerprint = null) { - const normPubkey = pubkey.trim(); +export async function getOrCreateAnonUserByCredential(credentialId, req = null, hwFingerprint = null) { const auditIp = req ? resolveAuditIP(req) : null; + + // Derive a stable "fingerprint" from the credential ID (for ban checks / display) + const fpBytes = crypto.createHash('sha256').update(Buffer.from(credentialId)).digest(); + const fingerprint = 'SHA256:' + fpBytes.toString('base64').replace(/=+$/, ''); + + // Check if we already have a row for this credential const existing = await db` - SELECT user_id FROM anon_identities - WHERE pubkey = ${normPubkey} + SELECT user_id FROM anon_identities + WHERE credential_id = ${credentialId} LIMIT 1 `; if (existing.length > 0) { await db` - UPDATE anon_identities + UPDATE anon_identities SET last_seen = NOW() ${auditIp ? db`, last_ip = ${auditIp}` : db``} ${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``} - WHERE pubkey = ${normPubkey} - `; - return { userId: existing[0].user_id, isNew: false }; + WHERE credential_id = ${credentialId} + `.catch(() => {}); + return { userId: existing[0].user_id, isNew: false, fingerprint }; } - // Generate unique shadow username - const shortHash = crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 8); + // Generate unique shadow username based on fingerprint short hash + const shortHash = fpBytes.toString('hex').slice(0, 8); let baseLogin = `anon_${shortHash}`; let finalLogin = baseLogin; let counter = 1; @@ -180,28 +102,28 @@ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFin `; await db` - INSERT INTO anon_identities (user_id, pubkey, fingerprint, created_ip, last_ip, hw_fingerprint) - VALUES (${userId}, ${normPubkey}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint}) - ON CONFLICT (pubkey) DO UPDATE + INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint) + VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint}) + ON CONFLICT (credential_id) DO UPDATE SET last_seen = NOW() ${auditIp ? db`, last_ip = ${auditIp}` : db``} ${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``} `; - return { userId, isNew: true }; + return { userId, isNew: true, fingerprint }; } /** - * Create a valid session in user_sessions for this anonymous user. - * @param {number} userId - * @param {object} req + * Create a valid session in user_sessions for an anonymous user. + * @param {number} userId + * @param {object} req * @param {string} [hwFingerprint] * @returns {Promise<{ session: string, csrf_token: string }>} */ export async function createAnonSession(userId, req, hwFingerprint = null) { const auditIp = resolveAuditIP(req); - // Update anon_identities last_ip, created_ip, and hw_fingerprint + // Update anon_identities last_ip and hw_fingerprint await db` UPDATE anon_identities SET last_ip = ${auditIp}, @@ -210,13 +132,13 @@ export async function createAnonSession(userId, req, hwFingerprint = null) { WHERE user_id = ${userId} `.catch(() => {}); - // 1. If req.session is already active for this exact userId, reuse it! + // If req.session is already active for this exact userId, reuse it if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) { await logAnonActivity(req, { action: 'handshake', hwFingerprint }); return { session: req.cookies.session, csrf_token: req.session.csrf_token }; } - // 2. If client has a session cookie that maps to this userId in DB, reuse it! + // If client has a session cookie that maps to this userId in DB, reuse it if (req?.cookies?.session) { const existingHash = lib.sha256(req.cookies.session); const existing = await db` @@ -245,7 +167,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) { browser: ua, created_at: stamp, last_used: stamp, - last_action: '/anon/session', + last_action: '/anon/passkey/auth', kmsi: 1, ip: ip }; diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index bb508c5..c2d4978 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -505,24 +505,7 @@ export default new class { }; async loggedin(req, res, next) { - if (!req.session) { - const sshPubkey = req.headers['x-ssh-pubkey']; - const sshTimestamp = parseInt(req.headers['x-ssh-timestamp'], 10); - const sshSig = req.headers['x-ssh-signature']; - if (sshPubkey && sshTimestamp && sshSig && Math.abs(Date.now() - sshTimestamp) <= 300000 && getEnableAnonymousAccess()) { - try { - const { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } = await import('./anon_auth.mjs'); - const message = `anon-auth:${sshTimestamp}:${sshPubkey}`; - if (verifySignature(sshPubkey, message, sshSig)) { - const parsed = parseOpenSshPubkey(sshPubkey); - const { userId } = await getOrCreateAnonUser(sshPubkey, parsed.fingerprint); - req.session = { id: userId, user: 'anonymous', display_name: 'Anonymous', is_anon: true, fingerprint: parsed.fingerprint }; - } - } catch (e) { - console.warn('[LIB_LOGGEDIN] Anon header auth failed:', e); - } - } - } + // SSH header auth removed (hard cut) — anonymous users must use passkey sessions if (!req.session) { return res.reply({ code: 401, diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index a7f819e..a6d9604 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -829,7 +829,9 @@ const f0cklib = { const rows = (await db` select items.id, + items.title, items.slug, + items.stamp, items.visibility, items.mime, items.dest, @@ -842,9 +844,10 @@ const f0cklib = { items.album_count, ${user_id ? db`max(coalesce(uvv.view_count, 0)) as my_views,` : db``} ${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`} - (case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag, + (case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' when min(ta.tag_id) = ${cfg.nsfl_tag_id || 3} then 'NSFL' else null end) as tag, min(ta.tag_id) as tag_id, max(uo.display_name) as display_name, + max(uo.username_color) as username_color, ${cfg.websrv.enable_dynamic_thumbs ? db` ( (SELECT count(*) FROM favorites WHERE item_id = items.id) + @@ -866,6 +869,9 @@ const f0cklib = { row.xd_tier = meta.tier; row.xd_label = meta.label; row.is_audio = !!(row.mime && row.mime.startsWith('audio/')); + const tId = row.tag_id != null ? Number(row.tag_id) : null; + row.rating_class = tId === 1 ? 'sfw' : (tId === 2 ? 'nsfw' : (tId === nsflId ? 'nsfl' : 'untagged')); + row.rating_label = tId === 1 ? 'SFW' : (tId === 2 ? 'NSFW' : (tId === nsflId ? 'NSFL' : '?')); } if (rows.some(r => r.is_album)) { @@ -2385,7 +2391,11 @@ const f0cklib = { } const items = rows.map(r => r.slug || String(r.id)); - return { items, total: items.length, sampled: items.length >= 10000 }; + const idMap = {}; + for (const r of rows) { + if (r.slug) idMap[r.slug] = r.id; + } + return { items, id_map: idMap, total: items.length, sampled: items.length >= 10000 }; }, getComments: async (itemId, sort = 'new', process = true) => { const numericId = await resolveNumericItemId(itemId); @@ -2556,6 +2566,7 @@ const f0cklib = { } }, getSubscriptionStatus: async (userId, itemId) => { + if (cfg.enable_comments === false) return false; const numericId = await resolveNumericItemId(itemId); if (!userId || !numericId) return false; const tStart = Date.now(); diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index b409421..f22a3b5 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -12,7 +12,7 @@ import cfg from "../config.mjs"; import security from "../security.mjs"; import crypto from "crypto"; import path from "path"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs, getBrandImageUrl } from "../settings.mjs"; export default (router, tpl) => { router.get(/^\/login(\/)?$/, async (req, res) => { @@ -299,6 +299,7 @@ export default (router, tpl) => { enable_cleanup: getEnableCleanup(), shitpost_mode: getShitpostMode(), enable_cleanup_config: cfg.websrv.enable_cleanup !== false, + current_brand_image: getBrandImageUrl(), tmp: null }, req) }); diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index a039a0e..6f86f85 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -241,6 +241,21 @@ export default (router, tpl) => { } catch (_) {} } + const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3; + const itemNumericId = data.item?.id ? String(data.item.id) : itemid; + const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null')); + const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null))); + const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null); + const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session)); + const itemUser = isAnon + ? 'anonymous' + : (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous'); + const itemUsername = isAnon + ? 'anonymous' + : (data.item?.username || 'anonymous'); + const itemMime = data.item?.matching_sub_mime || data.item?.mime || null; + const itemDest = data.item?.matching_sub_dest || data.item?.dest || null; + res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -248,8 +263,16 @@ export default (router, tpl) => { pagination: paginationHtml, title: data.title, id: itemid, + numeric_id: itemNumericId, slug: data.item?.slug || null, page: itemPage, + thumb: itemThumb, + mode: itemMode, + tag_id: itemTagId, + mime: itemMime, + user: itemUser, + username: itemUsername, + dest: itemDest, is_random: true }) }); @@ -489,6 +512,21 @@ export default (router, tpl) => { } catch (_) {} } + const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3; + const itemNumericId = data.item?.id ? String(data.item.id) : (/^\d+$/.test(itemid) ? itemid : null); + const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null')); + const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null))); + const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null); + const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session)); + const itemUser = isAnon + ? 'anonymous' + : (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous'); + const itemUsername = isAnon + ? 'anonymous' + : (data.item?.username || 'anonymous'); + const itemMime = data.item?.matching_sub_mime || data.item?.mime || null; + const itemDest = data.item?.matching_sub_dest || data.item?.dest || null; + res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -496,8 +534,16 @@ export default (router, tpl) => { pagination: paginationHtml, title: data.title, id: itemid, + numeric_id: itemNumericId, slug: data.item?.slug || null, - page: itemPage + page: itemPage, + thumb: itemThumb, + mode: itemMode, + tag_id: itemTagId, + mime: itemMime, + user: itemUser, + username: itemUsername, + dest: itemDest }) }); }); diff --git a/src/inc/routes/apiv2/anon.mjs b/src/inc/routes/apiv2/anon.mjs index b208240..7013cff 100644 --- a/src/inc/routes/apiv2/anon.mjs +++ b/src/inc/routes/apiv2/anon.mjs @@ -1,17 +1,26 @@ +import crypto from 'node:crypto'; import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; import security from '../../security.mjs'; -import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs'; +import { + getOrCreateAnonUserByCredential, + createAnonSession, + resolveAuditIP +} from '../../anon_auth.mjs'; +import { + generateChallenge, consumeChallenge, + verifyRegistration, verifyAuthentication, + buildRegistrationOptions, buildAuthenticationOptions, + base64url, fromBase64url, getRpIdFromHost +} from '../../webauthn.mjs'; import { getEnableAnonymousAccess } from '../../settings.mjs'; export default router => { router.group(/^\/api\/v2\/anon/, group => { - /** - * POST /api/v2/anon/session - * Authenticate via OpenSSH Ed25519 signature and establish an anonymous session. - */ + // ─── Helpers ───────────────────────────────────────────────────────────── + const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => { if (!sourceReason) return prefix; let clean = sourceReason; @@ -30,123 +39,60 @@ export default router => { res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`); }; - group.post(/\/session$/, async (req, res) => { - try { - if (!getEnableAnonymousAccess()) { - return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); - } - - 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, - msg: 'YOU ARE BANNED!', - reason: ipBan.reason || 'IP address is banned', - expires: ipBan.expires ? new Date(ipBan.expires).toLocaleString() : 'Permanent', - redirect: '/banned' - }, 403); - } - - const body = req.post || req.body || {}; - const pubkey = (body.pubkey || '').trim(); - const timestamp = parseInt(body.timestamp, 10); - const signature = (body.signature || '').trim(); - - if (!pubkey || !timestamp || !signature) { - return res.json({ success: false, msg: 'Missing pubkey, timestamp, or signature' }, 400); - } - - // Freshness check (5-minute window for clock skew) - const now = Date.now(); - if (Math.abs(now - timestamp) > 300000) { - return res.json({ success: false, msg: 'Timestamp expired or out of bounds' }, 401); - } - - const message = `anon-auth:${timestamp}:${pubkey}`; - const isValid = verifySignature(pubkey, message, signature); - if (!isValid) { - return res.json({ success: false, msg: 'Invalid Ed25519 signature' }, 401); - } - - const parsed = parseOpenSshPubkey(pubkey); - const hwFingerprint = (body.hw_fingerprint || '').trim() || null; - - // Check tombstone token sent from client (fingerprint or hardware ID) - if (body.tombstone && body.tombstone.banned) { - const tombstoneFp = body.tombstone.fingerprint; - const tombstoneHw = body.tombstone.hw_fingerprint; - const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null; - const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null; - const activeTombstoneBan = tombstoneBan || tombstoneHwBan; - - if (activeTombstoneBan) { - 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, - fingerprint: parsed.fingerprint, - hw_fingerprint: hwFingerprint || tombstoneHw, - msg: 'YOU ARE BANNED!', - reason: activeTombstoneBan.reason || 'Device is banned', - expires: activeTombstoneBan.expires ? new Date(activeTombstoneBan.expires).toLocaleString() : 'Permanent', - redirect: '/banned' - }, 403); + const checkAndCascadeBans = async (res, fingerprint, hwFingerprint, credentialId, tombstone) => { + // Tombstone cascade + if (tombstone && tombstone.banned) { + const tombstoneFp = tombstone.fingerprint; + const tombstoneHw = tombstone.hw_fingerprint; + const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null; + const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null; + const activeTombstoneBan = tombstoneBan || tombstoneHwBan; + if (activeTombstoneBan) { + const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false; + if (!alreadyFpBanned && fingerprint) { + await security.banAnonymousUser({ + fingerprint, + hwFingerprint: hwFingerprint || tombstoneHw, + bannedBy: activeTombstoneBan.banned_by, + reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'), + expires: activeTombstoneBan.expires, + banIps: true, + banHardware: true + }); } + return activeTombstoneBan; } + } - // Check hardware fingerprint ban - if (hwFingerprint) { - const hwBan = await security.isHardwareBanned(hwFingerprint); - if (hwBan) { - 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, - fingerprint: parsed.fingerprint, - hw_fingerprint: hwFingerprint, - msg: 'YOU ARE BANNED!', - reason: hwBan.reason || 'Hardware ID is banned', - expires: hwBan.expires ? new Date(hwBan.expires).toLocaleString() : 'Permanent', - redirect: '/banned' - }, 403); + // Hardware fingerprint ban + if (hwFingerprint) { + const hwBan = await security.isHardwareBanned(hwFingerprint); + if (hwBan) { + const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false; + if (!alreadyFpBanned && fingerprint) { + await security.banAnonymousUser({ + fingerprint, + hwFingerprint, + bannedBy: hwBan.banned_by, + reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'), + expires: hwBan.expires, + banIps: true, + banHardware: true + }); } + return hwBan; } + } - // Check fingerprint ban - const fpBan = await security.isFingerprintBanned(parsed.fingerprint); + // Fingerprint ban + if (fingerprint) { + const fpBan = await security.isFingerprintBanned(fingerprint); if (fpBan) { if (hwFingerprint) { const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint); if (!alreadyHwBanned) { await security.banAnonymousUser({ - fingerprint: parsed.fingerprint, + fingerprint, hwFingerprint, bannedBy: fpBan.banned_by, reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'), @@ -156,69 +102,332 @@ export default router => { }); } } - setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires); + return fpBan; + } + } + + return null; + }; + + // ─── Deprecated SSH endpoint — hard cut ─────────────────────────────────── + + group.post(/\/session$/, async (req, res) => { + return res.json({ + success: false, + msg: 'SSH-key anonymous authentication has been replaced by passkeys. Please refresh the page.' + }, 410); + }); + + // ─── Passkey Registration ───────────────────────────────────────────────── + + /** + * POST /api/v2/anon/passkey/register/begin + * Returns WebAuthn registration options (challenge + rp + user config). + * The client does NOT need to be logged in. + */ + group.post(/\/passkey\/register\/begin$/, async (req, res) => { + try { + if (!getEnableAnonymousAccess()) { + return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); + } + + 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, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403); + } + + const challenge = generateChallenge({ type: 'anon-register' }); + + // Generate a temporary opaque user handle (32 random bytes, base64url) + // This will be replaced by the real user_id after finish, but WebAuthn requires + // a user.id at registration time. We store it in the challenge. + const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16)))); + // Store the user handle in the challenge so finish can retrieve it + // (The challenge entry is keyed by challenge string) + // We re-issue the challenge with the user handle attached + const challengeWithHandle = generateChallenge({ type: 'anon-register', userHandle }); + + // Temporary display name for the registration prompt + const tmpName = `anon_new@${cfg.main?.url?.domain || 'f0ck.dev'}`; + + const options = buildRegistrationOptions({ + challenge: challengeWithHandle, + userId: userHandle, + userName: tmpName, + displayName: 'Anonymous', + rpId: getRpIdFromHost(req.headers.host) + }); + + return res.json({ success: true, options }); + } catch (err) { + console.error('[ANON_PASSKEY] register/begin error:', err); + return res.json({ success: false, msg: err.message || 'Internal server error' }, 500); + } + }); + + /** + * POST /api/v2/anon/passkey/register/finish + * Verify attestation, create shadow user + credential, establish session. + */ + group.post(/\/passkey\/register\/finish$/, async (req, res) => { + try { + if (!getEnableAnonymousAccess()) { + return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); + } + + 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, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403); + } + + const body = req.post || req.body || {}; + const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body; + + if (!challenge || !clientDataJSON || !attestationObject || !credentialId) { + return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400); + } + + // Consume and verify challenge + let challengeMeta; + try { + challengeMeta = consumeChallenge(challenge); + } catch (e) { + return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400); + } + if (challengeMeta.type !== 'anon-register') { + return res.json({ success: false, msg: 'Wrong challenge type' }, 400); + } + + // Verify the attestation + let regResult; + try { + regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) }); + } catch (e) { + console.warn('[ANON_PASSKEY] Registration verification failed:', e.message); + return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400); + } + + // Get fingerprint before ban checks (derived from credentialId) + const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, ''); + + // Ban checks + const ban = await checkAndCascadeBans(res, fingerprint, hwFingerprint || null, credentialId, tombstone || null); + if (ban) { + setBanCookie(res, ban.reason || 'Banned', ban.expires); return res.json({ - success: false, - banned: true, - fingerprint: parsed.fingerprint, - hw_fingerprint: hwFingerprint, - msg: 'YOU ARE BANNED!', - reason: fpBan.reason || 'Key fingerprint is banned', - expires: fpBan.expires ? new Date(fpBan.expires).toLocaleString() : 'Permanent', + success: false, banned: true, + fingerprint, hw_fingerprint: hwFingerprint, + msg: 'YOU ARE BANNED!', reason: ban.reason, + expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent', redirect: '/banned' }, 403); } - const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint, req, hwFingerprint); + // Get or create shadow user + const { userId, isNew, fingerprint: fp } = await getOrCreateAnonUserByCredential( + credentialId, req, hwFingerprint || null + ); // Check user table ban 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]; - 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, - fingerprint: parsed.fingerprint, - hw_fingerprint: hwFingerprint, - msg: 'YOU ARE BANNED!', - reason: u.ban_reason || 'Banned', - expires: u.ban_expires ? new Date(u.ban_expires).toLocaleString() : 'Permanent', - redirect: '/banned' - }, 403); + return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403); } - const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint); + // Store / update passkey credential in passkey_credentials + await db` + INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name) + VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'}) + ON CONFLICT (credential_id) DO UPDATE + SET sign_count = ${regResult.signCount}, last_used = NOW() + `; + const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null); res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`); return res.json({ success: true, is_new: isNew, user_id: userId, - fingerprint: parsed.fingerprint, - short_fingerprint: parsed.shortFingerprint, - hw_fingerprint: hwFingerprint, - csrf_token: csrf_token + fingerprint: fp, + short_fingerprint: fp.slice(7, 15), + credential_id: credentialId, + hw_fingerprint: hwFingerprint || null, + csrf_token }); } catch (err) { - console.error('[ANON_AUTH] Session establishment error:', err); + console.error('[ANON_PASSKEY] register/finish error:', err); return res.json({ success: false, msg: err.message || 'Internal server error' }, 500); } }); + // ─── Passkey Authentication ─────────────────────────────────────────────── + + /** + * POST /api/v2/anon/passkey/auth/begin + * Returns authentication options. allowCredentials is empty (discoverable credential flow). + */ + group.post(/\/passkey\/auth\/begin$/, async (req, res) => { + try { + if (!getEnableAnonymousAccess()) { + return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); + } + + 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, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403); + } + + const challenge = generateChallenge({ type: 'anon-auth' }); + const options = buildAuthenticationOptions({ + challenge, + allowCredentials: [], // discoverable — let the browser/Bitwarden pick + rpId: getRpIdFromHost(req.headers.host) + }); + + return res.json({ success: true, options }); + } catch (err) { + console.error('[ANON_PASSKEY] auth/begin error:', err); + return res.json({ success: false, msg: err.message || 'Internal server error' }, 500); + } + }); + + /** + * POST /api/v2/anon/passkey/auth/finish + * Verify assertion, establish anonymous session. + */ + group.post(/\/passkey\/auth\/finish$/, async (req, res) => { + try { + if (!getEnableAnonymousAccess()) { + return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); + } + + 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, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403); + } + + const body = req.post || req.body || {}; + const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body; + + if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) { + return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400); + } + + // Consume challenge + let challengeMeta; + try { + challengeMeta = consumeChallenge(challenge); + } catch (e) { + return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400); + } + if (challengeMeta.type !== 'anon-auth') { + return res.json({ success: false, msg: 'Wrong challenge type' }, 400); + } + + // Look up stored credential + const credRows = await db` + SELECT pc.user_id, pc.public_key_spki, pc.sign_count, ai.fingerprint + FROM passkey_credentials pc + LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = ${credentialId} + WHERE pc.credential_id = ${credentialId} + LIMIT 1 + `; + + if (credRows.length === 0) { + return res.json({ success: false, msg: 'Passkey not registered. Please register first.' }, 401); + } + + const { user_id: userId, public_key_spki: spki, sign_count: storedSignCount, fingerprint } = credRows[0]; + + // Verify the assertion + let authResult; + try { + authResult = await verifyAuthentication({ + challenge, + clientDataJSON, + authenticatorData, + signature, + spki, + storedSignCount, + rpId: getRpIdFromHost(req.headers.host) + }); + } catch (e) { + console.warn('[ANON_PASSKEY] Auth verification failed:', e.message); + return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401); + } + + // Derive fingerprint if not stored yet (legacy or first-time) + const fpForBan = fingerprint || (() => { + return 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, ''); + })(); + + // Ban checks + const ban = await checkAndCascadeBans(res, fpForBan, hwFingerprint || null, credentialId, tombstone || null); + if (ban) { + setBanCookie(res, ban.reason || 'Banned', ban.expires); + return res.json({ + success: false, banned: true, + fingerprint: fpForBan, hw_fingerprint: hwFingerprint, + msg: 'YOU ARE BANNED!', reason: ban.reason, + expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent', + redirect: '/banned' + }, 403); + } + + // Check user table ban + 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]; + setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires); + return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403); + } + + // Update sign count and last_used + await db` + UPDATE passkey_credentials + SET sign_count = ${authResult.newSignCount}, last_used = NOW() + WHERE credential_id = ${credentialId} + `; + + // Update anon_identities (hw_fingerprint, last_seen) + await db` + UPDATE anon_identities + SET last_seen = NOW() + ${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``} + WHERE user_id = ${userId} AND credential_id = ${credentialId} + `.catch(() => {}); + + const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null); + res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`); + + return res.json({ + success: true, + user_id: userId, + fingerprint: fpForBan, + short_fingerprint: fpForBan.slice(7, 15), + credential_id: credentialId, + hw_fingerprint: hwFingerprint || null, + csrf_token + }); + } catch (err) { + console.error('[ANON_PASSKEY] auth/finish error:', err); + return res.json({ success: false, msg: err.message || 'Internal server error' }, 500); + } + }); + + // ─── Identity ───────────────────────────────────────────────────────────── + /** * GET /api/v2/anon/identity * Get the current anonymous identity or registered user state. @@ -234,9 +443,11 @@ export default router => { } const rows = await db` - SELECT pubkey, fingerprint, hw_fingerprint, created_at, last_seen - FROM anon_identities - WHERE user_id = ${req.session.id} + SELECT ai.credential_id, ai.fingerprint, ai.hw_fingerprint, ai.created_at, ai.last_seen, + pc.name AS passkey_name, pc.aaguid + FROM anon_identities ai + LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id + WHERE ai.user_id = ${req.session.id} LIMIT 1 `; @@ -247,9 +458,10 @@ export default router => { is_anon: true, user_id: req.session.id, fingerprint: fp, - short_fingerprint: fp.slice(7, 15), + short_fingerprint: fp ? fp.slice(7, 15) : null, hw_fingerprint: rows[0].hw_fingerprint, - pubkey: rows[0].pubkey, + credential_id: rows[0].credential_id, + passkey_name: rows[0].passkey_name, csrf_token: req.session.csrf_token }); } @@ -262,11 +474,13 @@ export default router => { csrf_token: req.session.csrf_token }); } catch (err) { - console.error('[ANON_AUTH] Identity lookup error:', err); + console.error('[ANON_PASSKEY] Identity lookup error:', err); return res.json({ success: false, msg: err.message }, 500); } }); + // ─── Logout ─────────────────────────────────────────────────────────────── + /** * POST /api/v2/anon/logout * Clear anonymous session cookie and remove active session from database. @@ -282,7 +496,7 @@ export default router => { res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`); return res.json({ success: true }); } catch (err) { - console.error('[ANON_AUTH] Logout error:', err); + console.error('[ANON_PASSKEY] Logout error:', err); return res.json({ success: false, msg: err.message }, 500); } }); diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 8ea674b..34e4b83 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -9,7 +9,7 @@ import path from "path"; import f0cklib from '../../routeinc/f0cklib.mjs'; import audit from '../../audit.mjs'; import { parseMultipart, collectBody } from '../../multipart.mjs'; -import { purgeExpiredUploads } from '../../lib_delete.mjs'; +import { purgeExpiredUploads, safeDeleteMediaFile } from '../../lib_delete.mjs'; import { calculateExpiresAt } from './upload.mjs'; import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs'; import { logAnonActivity } from '../../anon_auth.mjs'; @@ -1714,6 +1714,147 @@ export default router => { }); }); + group.post(/\/admin\/delete-album-item$/, lib.loggedin, async (req, res) => { + const postid = +(req.post?.postid ?? req.body?.postid); + if (!postid || postid <= 0) { + return res.json({ success: false, msg: 'Invalid postid' }, 400); + } + + const items = await db` + SELECT id, dest, mime, username, is_album, album_count, is_deleted, active + FROM items + WHERE id = ${postid} AND active = true AND is_deleted = false + LIMIT 1 + `; + if (!items.length) { + return res.json({ success: false, msg: 'Item not found' }, 404); + } + const item = items[0]; + if (!item.is_album) { + return res.json({ success: false, msg: 'Item is not an album' }, 400); + } + + const isMod = req.session.admin || req.session.is_moderator; + const isOwner = req.session.user && item.username && req.session.user.toLowerCase() === item.username.toLowerCase(); + if (!isMod && !isOwner) { + return res.json({ success: false, msg: 'Unauthorized' }, 403); + } + + const subId = req.post?.sub_id ?? req.post?.subf0ck_id ?? req.body?.sub_id; + const subSlug = req.post?.sub_slug ?? req.body?.sub_slug; + const subIndex = req.post?.order_index ?? req.body?.order_index; + + const allSubs = await db` + SELECT id, item_id, dest, mime, size, checksum, order_index, slug + FROM album_items + WHERE item_id = ${postid} + ORDER BY order_index ASC + `; + if (!allSubs.length) { + return res.json({ success: false, msg: 'No album items found' }, 404); + } + + let targetSub = null; + if (subId !== undefined && subId !== null && subId !== '') { + targetSub = allSubs.find(s => s.id === +subId || s.slug === String(subId)); + } + if (!targetSub && subSlug) { + targetSub = allSubs.find(s => s.slug === String(subSlug)); + } + if (!targetSub && subIndex !== undefined && subIndex !== null && subIndex !== '') { + targetSub = allSubs.find(s => s.order_index === +subIndex); + } + + if (!targetSub) { + return res.json({ success: false, msg: 'Album sub-item not found' }, 404); + } + + const reason = req.post?.reason || req.body?.reason || 'No reason provided'; + + // If this is the last remaining item in the album, delete the whole post + if (allSubs.length <= 1) { + await safeDeleteMediaFile(item.dest, postid); + const thumbName = `${postid}.webp`; + await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {}); + await fs.unlink(path.join(cfg.paths.t, `${postid}_blur.webp`)).catch(() => {}); + if (item.mime?.startsWith('audio')) { + await fs.unlink(path.join(cfg.paths.ca, thumbName)).catch(() => {}); + } + await db`DELETE FROM album_items_tags_assign WHERE album_item_id = ${targetSub.id}`.catch(() => {}); + await db`DELETE FROM album_items WHERE id = ${targetSub.id}`.catch(() => {}); + await db`UPDATE items SET active = false, is_deleted = true WHERE id = ${postid}`; + await audit.log(req.session.id, 'delete_item', 'item', postid, { filename: item.dest, reason }); + db.notify('delete_item', JSON.stringify({ id: postid })).catch(() => {}); + return res.json({ success: true, post_deleted: true, msg: 'Last album item removed, post deleted' }); + } + + // Multi-item album: safely remove this sub-item media + if (targetSub.dest && !targetSub.dest.startsWith('yt:')) { + await safeDeleteMediaFile(targetSub.dest, postid); + const subBase = targetSub.dest.replace(/\.[^.]+$/, ''); + await fs.unlink(path.join(cfg.paths.t, `${subBase}.webp`)).catch(() => {}); + if (targetSub.mime?.startsWith('audio')) { + await fs.unlink(path.join(cfg.paths.ca, `${subBase}.webp`)).catch(() => {}); + } + } else if (targetSub.dest?.startsWith('yt:')) { + await fs.unlink(path.join(cfg.paths.t, `${targetSub.id}.webp`)).catch(() => {}); + } + + await db`DELETE FROM album_items_tags_assign WHERE album_item_id = ${targetSub.id}`.catch(() => {}); + await db`DELETE FROM album_items WHERE id = ${targetSub.id}`; + + const remainingSubs = allSubs.filter(s => s.id !== targetSub.id); + const isCoverDeleted = targetSub.order_index === 0; + + // Re-index remaining album items so order_index is contiguous + await db` + UPDATE album_items + SET order_index = order_index - 1 + WHERE item_id = ${postid} AND order_index > ${targetSub.order_index} + `; + + if (isCoverDeleted) { + const newCover = remainingSubs[0]; + await db` + UPDATE items + SET dest = ${newCover.dest}, + mime = ${newCover.mime}, + size = ${newCover.size || 0}, + checksum = ${newCover.checksum || ''}, + album_count = ${remainingSubs.length} + WHERE id = ${postid} + `; + try { + await queue.genThumbnail(newCover.dest, newCover.mime, postid, ''); + await queue.genBlurredThumbnail(postid); + } catch (thumbErr) { + console.error('[DELETE-ALBUM-ITEM] Failed to regen thumbnail for promoted cover:', thumbErr); + } + } else { + await db` + UPDATE items + SET album_count = ${remainingSubs.length} + WHERE id = ${postid} + `; + } + + await audit.log(req.session.id, 'delete_album_item', 'album_item', targetSub.id, { + postid, + filename: targetSub.dest, + order_index: targetSub.order_index, + reason + }); + + return res.json({ + success: true, + post_deleted: false, + deleted_sub_id: targetSub.id, + deleted_order_index: targetSub.order_index, + remaining_count: remainingSubs.length, + promoted_cover: isCoverDeleted + }); + }); + 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); diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index bf71155..c936779 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -5,6 +5,12 @@ import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; import { canAnonDo, isAnonSession } from '../../settings.mjs'; +import { + generateChallenge, consumeChallenge, + verifyRegistration, verifyAuthentication, + buildRegistrationOptions, buildAuthenticationOptions, + base64url, getRpIdFromHost +} from '../../webauthn.mjs'; // Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs // These routes remain for other settings API endpoints @@ -1194,6 +1200,274 @@ export default router => { } }); + + // ─── Passkey Management (registered users) ────────────────────────────── + + /** + * GET /api/v2/settings/passkeys + * List the current user's registered passkeys. + */ + group.get(/\/passkeys$/, lib.registeredUser, async (req, res) => { + try { + const rows = await db` + SELECT id, credential_id, name, aaguid, created_at, last_used + FROM passkey_credentials + WHERE user_id = ${req.session.id} + ORDER BY created_at DESC + `; + return res.json({ success: true, passkeys: rows }); + } catch (err) { + console.error('[PASSKEYS] List error:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + /** + * POST /api/v2/settings/passkeys/register/begin + * Generate WebAuthn registration options for a logged-in user. + */ + group.post(/\/passkeys\/register\/begin$/, lib.registeredUser, async (req, res) => { + try { + // Get existing credentials to exclude (prevent re-registering same authenticator) + const existing = await db` + SELECT credential_id FROM passkey_credentials + WHERE user_id = ${req.session.id} + `; + const excludeCredentials = existing.map(r => ({ id: r.credential_id, type: 'public-key' })); + + const challenge = generateChallenge({ type: 'user-register', userId: req.session.id }); + + // User handle: SHA256 of user_id encoded as base64url (stable, opaque) + const userHandle = base64url( + crypto.createHash('sha256').update(String(req.session.id)).digest().slice(0, 16) + ); + + const body = req.post || req.body || {}; + const passkeyName = (body.name || '').trim().slice(0, 64) || null; + + const options = buildRegistrationOptions({ + challenge, + userId: userHandle, + userName: req.session.login || req.session.user, + displayName: req.session.display_name || req.session.user, + excludeCredentials, + rpId: getRpIdFromHost(req.headers.host) + }); + + // Stash the intended passkey name in challenge metadata + if (passkeyName) { + // Re-consume and re-store with name (challenges are stored by their value) + // Simpler: store name in a temporary Map keyed by challenge + options._passkeyName = passkeyName; + } + + return res.json({ success: true, options, passkey_name: passkeyName }); + } catch (err) { + console.error('[PASSKEYS] register/begin error:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + /** + * POST /api/v2/settings/passkeys/register/finish + * Verify attestation and store new passkey for the logged-in user. + */ + group.post(/\/passkeys\/register\/finish$/, lib.registeredUser, async (req, res) => { + try { + const body = req.post || req.body || {}; + const { challenge, clientDataJSON, attestationObject, credentialId, name } = body; + + if (!challenge || !clientDataJSON || !attestationObject || !credentialId) { + return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400); + } + + // Consume and verify challenge + let challengeMeta; + try { + challengeMeta = consumeChallenge(challenge); + } catch (e) { + return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400); + } + if (challengeMeta.type !== 'user-register' || challengeMeta.userId !== req.session.id) { + return res.json({ success: false, msg: 'Challenge mismatch' }, 400); + } + + // Verify attestation + let regResult; + try { + regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) }); + } catch (e) { + console.warn('[PASSKEYS] Registration verification failed:', e.message); + return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400); + } + + const passkeyName = ((name || '').trim().slice(0, 64)) || 'Passkey'; + + await db` + INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name) + VALUES (${req.session.id}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${passkeyName}) + ON CONFLICT (credential_id) DO UPDATE + SET sign_count = ${regResult.signCount}, last_used = NOW(), name = ${passkeyName} + `; + + return res.json({ success: true, credential_id: credentialId, name: passkeyName }); + } catch (err) { + console.error('[PASSKEYS] register/finish error:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + /** + * POST /api/v2/settings/passkeys/delete + * Remove a passkey credential owned by the current user. + * Uses POST + JSON body to avoid URL-encoding issues with credential IDs. + */ + group.post(/\/passkeys\/delete$/, lib.registeredUser, async (req, res) => { + try { + const body = req.post || req.body || {}; + const credentialId = body.credential_id; + if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400); + const result = await db` + DELETE FROM passkey_credentials + WHERE credential_id = ${credentialId} AND user_id = ${req.session.id} + RETURNING id + `; + if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404); + return res.json({ success: true }); + } catch (err) { + console.error('[PASSKEYS] Delete error:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + /** + * DELETE /api/v2/settings/passkeys/:id + * Legacy path — kept for compatibility. + */ + group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => { + try { + const credentialId = decodeURIComponent(req.url.pathname.split('/').pop()); + const result = await db` + DELETE FROM passkey_credentials + WHERE credential_id = ${credentialId} AND user_id = ${req.session.id} + RETURNING id + `; + if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404); + return res.json({ success: true }); + } catch (err) { + console.error('[PASSKEYS] Delete error:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + /** + * POST /api/v2/settings/passkeys/login/begin + * Start a passkey login challenge for a registered user (no session required). + */ + group.post(/\/passkeys\/login\/begin$/, async (req, res) => { + try { + const challenge = generateChallenge({ type: 'user-login' }); + const options = buildAuthenticationOptions({ challenge, allowCredentials: [], rpId: getRpIdFromHost(req.headers.host) }); + return res.json({ success: true, options }); + } catch (err) { + console.error('[PASSKEYS] login/begin error:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + + /** + * POST /api/v2/settings/passkeys/login/finish + * Verify assertion and create a full registered-user session. + */ + group.post(/\/passkeys\/login\/finish$/, async (req, res) => { + try { + const body = req.post || req.body || {}; + const { challenge, clientDataJSON, authenticatorData, signature, credentialId } = body; + + if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) { + return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400); + } + + // Consume challenge + let challengeMeta; + try { + challengeMeta = consumeChallenge(challenge); + } catch (e) { + return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400); + } + if (challengeMeta.type !== 'user-login') { + return res.json({ success: false, msg: 'Wrong challenge type' }, 400); + } + + // Look up credential — must belong to a registered (non-anon) user + const credRows = await db` + SELECT pc.user_id, pc.public_key_spki, pc.sign_count, + u.login, u.user, u.banned, u.ban_reason, u.ban_expires, u.force_password_change + FROM passkey_credentials pc + JOIN "user" u ON u.id = pc.user_id + WHERE pc.credential_id = ${credentialId} + AND u.login IS NOT NULL + LIMIT 1 + `; + + if (credRows.length === 0) { + return res.json({ success: false, msg: 'No registered account found for this passkey.' }, 401); + } + + const row = credRows[0]; + + if (row.banned) { + return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403); + } + + // Verify the assertion + try { + await verifyAuthentication({ + challenge, + clientDataJSON, + authenticatorData, + signature, + spki: row.public_key_spki, + storedSignCount: row.sign_count, + rpId: getRpIdFromHost(req.headers.host) + }); + } catch (e) { + console.warn('[PASSKEYS] login/finish verification failed:', e.message); + return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401); + } + + // Update sign count + await db` + UPDATE passkey_credentials SET sign_count = sign_count + 1, last_used = NOW() + WHERE credential_id = ${credentialId} + `; + + // Create a full user session (same as normal login) + const stamp = Math.floor(Date.now() / 1000); + const ip = (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.socket?.remoteAddress || '').split(',')[0].trim(); + const sessionToken = crypto.randomBytes(32).toString('hex'); + const csrfToken = crypto.randomBytes(32).toString('hex'); + const sessRecord = { + user_id: row.user_id, + session: lib.sha256(sessionToken), + csrf_token: csrfToken, + browser: req.headers['user-agent'] || '', + created_at: stamp, + last_used: stamp, + last_action: '/passkey-login', + kmsi: 1, + ip + }; + await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}`; + + res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`); + return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false }); + } catch (err) { + console.error('[PASSKEYS] login/finish error:', err); + return res.json({ success: false, msg: err.message }, 500); + } + }); + return group; }); diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index cbc4e36..8529a13 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -481,9 +481,11 @@ export default router => { } // Auto-subscribe uploader - try { - await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`; - } catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); } + if (cfg.enable_comments !== false) { + try { + await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`; + } catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); } + } // Download YouTube thumbnail as our thumbnail try { @@ -820,9 +822,11 @@ export default router => { addPrivateItem(itemid, filename, session.user); } - try { - await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`; - } catch (err) { } + if (cfg.enable_comments !== false) { + try { + await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`; + } catch (err) { } + } try { await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired); diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index 7de64de..618751a 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -7,13 +7,12 @@ import audit from "../audit.mjs"; 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 { resolveAuditIP, logAnonActivity } from "../anon_auth.mjs"; import { getEnableAnonymousAccess, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from "../settings.mjs"; export default (router, tpl) => { - // Get comments for an item router.get(/\/api\/comments\/(?\d+)/, async (req, res) => { const itemId = req.params.itemid; @@ -399,28 +398,10 @@ export default (router, tpl) => { // Post a comment router.post('/api/comments', async (req, res) => { - if (!req.session) { - const sshPubkey = req.headers['x-ssh-pubkey']; - const sshTimestamp = parseInt(req.headers['x-ssh-timestamp'], 10); - const sshSig = req.headers['x-ssh-signature']; - if (sshPubkey && sshTimestamp && sshSig && getEnableAnonymousAccess()) { - const now = Date.now(); - if (Math.abs(now - sshTimestamp) <= 300000) { - const message = `anon-auth:${sshTimestamp}:${sshPubkey}`; - if (verifySignature(sshPubkey, message, sshSig)) { - try { - const parsed = parseOpenSshPubkey(sshPubkey); - const { userId } = await getOrCreateAnonUser(sshPubkey, parsed.fingerprint); - req.session = { id: userId, user: 'anonymous', display_name: 'Anonymous', is_anon: true, fingerprint: parsed.fingerprint }; - } catch (e) { - console.error('[ANON_COMMENTS] Auth header error:', e); - } - } - } - } - } + // Anonymous users must have an active passkey session — no SSH header fallback (hard cut) 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" }) }); } diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs index a5cedf4..16c6b35 100644 --- a/src/inc/routes/external.mjs +++ b/src/inc/routes/external.mjs @@ -382,9 +382,11 @@ export default (router) => { if (repost) { await fs.unlink(finalTmp).catch(() => {}); // Auto-subscribe user to the existing item they attempted to rehost - try { - await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`; - } catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); } + if (cfg.enable_comments !== false) { + try { + await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`; + } catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); } + } return res.reply({ code: 200, @@ -402,9 +404,11 @@ export default (router) => { if (phashMatch) { await fs.unlink(finalTmp).catch(() => {}); // Auto-subscribe user to the existing item they attempted to rehost (visual match) - try { - await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`; - } catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); } + if (cfg.enable_comments !== false) { + try { + await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`; + } catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); } + } return res.reply({ code: 200, @@ -444,9 +448,11 @@ export default (router) => { `; // Automatically subscribe user to the new item - try { - await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`; - } catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); } + if (cfg.enable_comments !== false) { + try { + await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`; + } catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); } + } // Process thumbnail try { diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 7e14317..f706a17 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -580,9 +580,32 @@ export default (router, tpl) => { it.is_onara_active = false; } } - // If not in first page of feed, unshift it so thumbnail is present in background + // If not in first page of feed, add it so thumbnail is present in background if (!foundInGrid && data.item) { - gridData.items.unshift({ ...data.item, is_onara_active: true }); + const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3; + const tagId = data.item.tag_id || (data.item.is_nsfl ? nsflId : (data.item.is_nsfw ? 2 : (data.item.is_sfw ? 1 : null))); + const thumbUrl = data.item.thumb || data.item.thumbnail || (data.item.id ? `/t/${data.item.id}.webp` : null); + const cleanDest = data.item.dest ? String(data.item.dest).replace(/^\/b\//, '') : ''; + const gridItem = { + ...data.item, + id: data.item.id, + slug: data.item.slug, + tag_id: tagId, + thumb: thumbUrl, + dest: cleanDest, + display_name: data.item.author_display_name || data.item.display_name || data.item.username, + username: data.item.username, + mime: data.item.matching_sub_mime || data.item.mime, + thumb_size: data.item.thumb_size || 1, + is_onara_active: true + }; + + if (isRandom && gridData.items.length > 0) { + const insertIdx = Math.floor(Math.random() * (gridData.items.length + 1)); + gridData.items.splice(insertIdx, 0, gridItem); + } else { + gridData.items.unshift(gridItem); + } } } diff --git a/src/inc/routes/mod.mjs b/src/inc/routes/mod.mjs index 42d8c31..ab252e0 100644 --- a/src/inc/routes/mod.mjs +++ b/src/inc/routes/mod.mjs @@ -422,16 +422,28 @@ export default (router, tpl) => { const page = +(req.url.qs?.page || 1); const limit = 50; const offset = (page - 1) * limit; + const filterAction = req.url.qs?.action?.trim() || ''; + const filterUser = req.url.qs?.user?.trim() || ''; const logs = await db` SELECT al.*, u.user as username FROM audit_log al LEFT JOIN "user" u ON al.user_id = u.id + WHERE true + ${filterAction ? db`AND al.action = ${filterAction}` : db``} + ${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``} ORDER BY al.created_at DESC LIMIT ${limit} OFFSET ${offset} `; - const totalResult = await db`SELECT count(*) as c FROM audit_log`; + const totalResult = await db` + SELECT count(*) as c + FROM audit_log al + LEFT JOIN "user" u ON al.user_id = u.id + WHERE true + ${filterAction ? db`AND al.action = ${filterAction}` : db``} + ${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``} + `; const total = totalResult[0].c; const pages = Math.ceil(total / limit); @@ -493,7 +505,9 @@ export default (router, tpl) => { logs: processed, page, pages, - hasMore: page < pages + hasMore: page < pages, + filterAction, + filterUser }); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); } @@ -506,6 +520,8 @@ export default (router, tpl) => { logs: processedLogs, page, pages, + filterAction, + filterUser, tmp: null }, req) }); diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs index f72165e..7152541 100644 --- a/src/inc/routes/notifications.mjs +++ b/src/inc/routes/notifications.mjs @@ -318,6 +318,20 @@ db.listen('motd', (payload) => { } }).catch(err => console.error('DB Listen MOTD error:', err)); +// Global listener for brand image updates +db.listen('brand_image', (payload) => { + try { + const data = JSON.parse(payload); + console.log(`[SSE] Broadcasting brand_image update to ${clients.size} clients`); + for (const client of clients) { + client.send({ type: 'brand_image', data: { url: data.url || null } }); + } + } catch (e) { + console.error('Brand image broadcast error:', e); + } +}).catch(err => console.error('DB Listen brand_image error:', err)); + + // Global listener for new items (live grid updates) db.listen('new_item', (payload) => { try { @@ -864,7 +878,7 @@ export default (router, tpl) => { // Notification History Page router.get('/notifications', async (req, res) => { if (!req.session) return res.redirect('/login'); - const tab = req.url.qs?.tab || 'user'; + const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs?.tab || 'user'); const data = await getNotificationHistory(req.session.id, 1, 50, tab); data.session = req.session; data.hidePagination = true; @@ -885,7 +899,7 @@ export default (router, tpl) => { success: false }, 401); const page = parseInt(req.url.qs.page) || 1; - const tab = req.url.qs.tab || null; + const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs.tab || null); const data = await getNotificationHistory(req.session.id, page, 50, tab); const html = tpl.render('snippets/notifications-list', { ...data, active_mode: req.session?.mode ?? 0 }, req); diff --git a/src/inc/routes/reports.mjs b/src/inc/routes/reports.mjs index 710c4f8..35f3e51 100644 --- a/src/inc/routes/reports.mjs +++ b/src/inc/routes/reports.mjs @@ -10,9 +10,21 @@ export default (router, tpl) => { router.post(/^\/api\/v2\/report\/?$/, async (req, res) => { try { const { item_id, comment_id, reported_user_id, reason } = req.post; + // The framework's readBody uses Object.fromEntries which loses duplicate keys + // and decodeURIComponent on an array joins it as comma-separated string. + // So categories[]= ends up as a single comma-joined string — split it back. + const VALID_CATEGORIES = ['wrong_rating', 'spam', 'duplicate', 'copyright', 'illegal', 'other']; + let rawCats = req.post['categories[]'] || req.post['categories'] || ''; + let categories = []; + if (Array.isArray(rawCats)) { + categories = rawCats; + } else if (typeof rawCats === 'string' && rawCats.length > 0) { + categories = rawCats.split(',').map(s => s.trim()); + } + categories = categories.filter(c => VALID_CATEGORIES.includes(c)); - if (!reason || reason.trim().length === 0) { - return res.json({ success: false, msg: "Reason is required." }, 400); + if ((!reason || reason.trim().length === 0) && categories.length === 0) { + return res.json({ success: false, msg: "Please select at least one reason or provide a description." }, 400); } // At least one target must be specified @@ -50,14 +62,15 @@ export default (router, tpl) => { } const reportRes = await db` - INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason) + INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories) VALUES ( ${req.session ? req.session.id : null}, ${ip}, ${item_id ? +item_id : null}, ${comment_id ? +comment_id : null}, ${reported_user_id ? +reported_user_id : null}, - ${reason.trim()} + ${reason ? reason.trim() : ''}, + ${db.array(categories)} ) RETURNING id `; @@ -108,6 +121,7 @@ export default (router, tpl) => { COALESCE(tgt_u.user, tgt_auth.user, comm_auth.user) AS reported_user_name, COALESCE(NULLIF(r.user_id, 0), tgt_auth.id, comm_auth.id) AS reported_user_id, COALESCE(tgt_u.admin, tgt_auth.admin, comm_auth.admin) AS reported_user_is_admin, + resolver.user AS resolver_name, i.dest AS item_dest, tgt_auth.id AS item_user_id, tgt_auth.user AS item_user_name, @@ -123,6 +137,7 @@ export default (router, tpl) => { FROM reports r LEFT JOIN "user" rep ON r.reporter_id = rep.id LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id + LEFT JOIN "user" resolver ON r.resolved_by = resolver.id LEFT JOIN items i ON r.item_id = i.id LEFT JOIN "user" tgt_auth ON i.username = tgt_auth.user LEFT JOIN comments c ON r.comment_id = c.id diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index bf86db5..20d61d5 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -246,3 +246,12 @@ export const setNsfpIds = (ids) => { cfg.nsfp = [...nsfp_ids]; }; +// Brand image URL — stored in site_settings DB, not config.json +// Falls back to cfg.websrv.custom_brand_image (array or string) on first boot +let brand_image_url = (() => { + const raw = cfg.websrv?.custom_brand_image; + return Array.isArray(raw) ? (raw[0] || '') : (raw || ''); +})(); + +export const getBrandImageUrl = () => brand_image_url; +export const setBrandImageUrl = (val) => { brand_image_url = val || ''; }; diff --git a/src/inc/trigger/parser.mjs b/src/inc/trigger/parser.mjs index 25221bd..3251d00 100644 --- a/src/inc/trigger/parser.mjs +++ b/src/inc/trigger/parser.mjs @@ -760,7 +760,7 @@ export default async bot => { // Auto-subscribe uploader try { - if (websiteUser?.id) { + if (cfg.enable_comments !== false && websiteUser?.id) { await db` INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${websiteUser.id}, ${itemid}) @@ -867,7 +867,7 @@ export default async bot => { // Auto-subscribe uploader try { - if (websiteUser?.id) { + if (cfg.enable_comments !== false && websiteUser?.id) { await db` INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${websiteUser.id}, ${itemid}) diff --git a/src/inc/webauthn.mjs b/src/inc/webauthn.mjs new file mode 100644 index 0000000..06a9445 --- /dev/null +++ b/src/inc/webauthn.mjs @@ -0,0 +1,474 @@ +/** + * webauthn.mjs — Pure Node.js WebAuthn / Passkey helpers (ES-256 / P-256 only) + * + * No external dependencies — uses Node.js built-in `crypto` (WebCrypto). + * + * Supported algorithm: ES-256 (COSE alg -7, P-256). + * Supported attestation formats: "none" (passkey managers always send "none"). + * + * Challenge store: in-memory Map with TTL. Challenges are single-use. + */ + +import crypto from 'node:crypto'; +import cfg from './config.mjs'; + +// --------------------------------------------------------------------------- +// Challenge store (in-memory, single-use, 5-minute TTL) +// --------------------------------------------------------------------------- + +const challengeStore = new Map(); +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +setInterval(() => { + const now = Date.now(); + for (const [k, v] of challengeStore) { + if (now > v.expiresAt) challengeStore.delete(k); + } +}, 60_000); + +/** + * Generate a random challenge and store it with optional metadata. + * @param {object} [meta] - e.g. { userId, type } + * @returns {string} base64url-encoded challenge + */ +export function generateChallenge(meta = {}) { + const buf = crypto.getRandomValues(new Uint8Array(32)); + const challenge = base64url(buf); + challengeStore.set(challenge, { ...meta, expiresAt: Date.now() + CHALLENGE_TTL_MS }); + return challenge; +} + +/** + * Consume (use-once) a challenge. Returns stored metadata or throws. + * @param {string} challenge base64url + * @returns {object} stored metadata + */ +export function consumeChallenge(challenge) { + const entry = challengeStore.get(challenge); + if (!entry) throw new Error('Challenge not found or expired'); + if (Date.now() > entry.expiresAt) { + challengeStore.delete(challenge); + throw new Error('Challenge expired'); + } + challengeStore.delete(challenge); + return entry; +} + +// --------------------------------------------------------------------------- +// Relying Party config +// --------------------------------------------------------------------------- + +function getRpId(override) { + // Explicit override wins (e.g. derived from request Host header for localhost dev) + if (override) return override; + return cfg.websrv?.passkey_rp_id || cfg.main?.url?.domain || 'localhost'; +} + +/** + * Derive an rpId from a Host header value. + * e.g. "localhost:1337" → "localhost", "f0ck.dev" → "f0ck.dev" + * Use this in route handlers and pass the result to build-x and verify-x functions. + */ +export function getRpIdFromHost(hostHeader) { + if (!hostHeader) return null; + return hostHeader.split(':')[0] || null; +} + +function getExpectedOrigins(rpId) { + const origins = [`https://${rpId}`]; + // Also allow http for localhost dev + if (rpId === 'localhost' || rpId.startsWith('127.') || rpId === '[::1]') { + origins.push(`http://${rpId}`); + // Also allow http://localhost:PORT + if (cfg.websrv?.port) origins.push(`http://${rpId}:${cfg.websrv.port}`); + } + // Allow any extra origins from config + if (Array.isArray(cfg.websrv?.passkey_extra_origins)) { + origins.push(...cfg.websrv.passkey_extra_origins); + } + return origins; +} + +// --------------------------------------------------------------------------- +// Base64url helpers +// --------------------------------------------------------------------------- + +export function base64url(buf) { + const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + return b.toString('base64url'); +} + +export function fromBase64url(str) { + return Buffer.from(str, 'base64url'); +} + +function fromBase64(str) { + return Buffer.from(str, 'base64'); +} + +function toBase64(buf) { + return Buffer.from(buf).toString('base64'); +} + +// --------------------------------------------------------------------------- +// CBOR minimal decoder (only what we need for authenticatorData / COSE key) +// --------------------------------------------------------------------------- + +/** + * Minimal CBOR decoder supporting: + * - unsigned int (major 0) + * - negative int (major 1) + * - byte string (major 2) + * - text string (major 3) + * - array (major 4) + * - map (major 5) + * - simple/float (major 7) — ignored/skipped + */ +function cborDecode(buf, offset = 0) { + const [value, newOffset] = _cborDecodeItem(buf, offset); + return value; +} + +function _cborDecodeItem(buf, offset) { + const byte = buf[offset++]; + const major = (byte >> 5) & 0x7; + const info = byte & 0x1f; + + let additionalInt; + if (info < 24) { + additionalInt = info; + } else if (info === 24) { + additionalInt = buf[offset++]; + } else if (info === 25) { + additionalInt = (buf[offset] << 8) | buf[offset + 1]; + offset += 2; + } else if (info === 26) { + additionalInt = (buf[offset] << 24 | buf[offset+1] << 16 | buf[offset+2] << 8 | buf[offset+3]) >>> 0; + offset += 4; + } else if (info === 27) { + // 64-bit uint — read as BigInt then convert (sign_count fits in 53-bit JS int normally) + const hi = (buf[offset] << 24 | buf[offset+1] << 16 | buf[offset+2] << 8 | buf[offset+3]) >>> 0; + const lo = (buf[offset+4] << 24 | buf[offset+5] << 16 | buf[offset+6] << 8 | buf[offset+7]) >>> 0; + additionalInt = hi * 0x100000000 + lo; + offset += 8; + } else { + throw new Error(`CBOR: unsupported additional info ${info}`); + } + + switch (major) { + case 0: return [additionalInt, offset]; + case 1: return [-(additionalInt + 1), offset]; + case 2: { + const slice = buf.slice(offset, offset + additionalInt); + return [slice, offset + additionalInt]; + } + case 3: { + const str = buf.slice(offset, offset + additionalInt).toString('utf8'); + return [str, offset + additionalInt]; + } + case 4: { + const arr = []; + for (let i = 0; i < additionalInt; i++) { + let item; + [item, offset] = _cborDecodeItem(buf, offset); + arr.push(item); + } + return [arr, offset]; + } + case 5: { + const map = {}; + for (let i = 0; i < additionalInt; i++) { + let key, val; + [key, offset] = _cborDecodeItem(buf, offset); + [val, offset] = _cborDecodeItem(buf, offset); + map[key] = val; + } + return [map, offset]; + } + case 7: { + // simple/float — skip + if (info < 20) return [undefined, offset]; + if (info === 20) return [false, offset]; + if (info === 21) return [true, offset]; + if (info === 22) return [null, offset]; + if (info === 25) return [undefined, offset + 2]; // float16, skip + if (info === 26) return [undefined, offset + 4]; // float32, skip + if (info === 27) return [undefined, offset + 8]; // float64, skip + return [undefined, offset]; + } + default: + throw new Error(`CBOR: unsupported major type ${major}`); + } +} + +// --------------------------------------------------------------------------- +// Parse authenticatorData (binary, defined in WebAuthn spec) +// --------------------------------------------------------------------------- + +function parseAuthenticatorData(buf) { + // 32 bytes rpIdHash + 1 byte flags + 4 bytes signCount + optional attested cred data + if (buf.length < 37) throw new Error('authenticatorData too short'); + + const rpIdHash = buf.slice(0, 32); + const flags = buf[32]; + const signCount = buf.readUInt32BE(33); + + const UP = (flags & 0x01) !== 0; // user presence + const UV = (flags & 0x04) !== 0; // user verification + const AT = (flags & 0x40) !== 0; // attested credential data included + const ED = (flags & 0x80) !== 0; // extension data included + + let credentialData = null; + let offset = 37; + + if (AT) { + const aaguid = buf.slice(offset, offset + 16); + offset += 16; + const credIdLen = buf.readUInt16BE(offset); + offset += 2; + const credentialId = buf.slice(offset, offset + credIdLen); + offset += credIdLen; + // Remaining bytes (up to ED extension) are COSE public key + const coseKeyBuf = ED + ? buf.slice(offset) // we'll just parse until COSE map ends + : buf.slice(offset); + const coseKey = cborDecode(coseKeyBuf); + credentialData = { aaguid, credentialId, coseKey }; + } + + return { rpIdHash, flags: { UP, UV, AT, ED }, signCount, credentialData }; +} + +// --------------------------------------------------------------------------- +// Convert COSE ES-256 public key map to raw P-256 point (uncompressed) +// --------------------------------------------------------------------------- + +function coseToSpki(coseKey) { + // COSE map keys: 1=kty, 3=alg, -1=crv, -2=x, -3=y + const kty = coseKey[1]; + const alg = coseKey[3]; + if (kty !== 2) throw new Error(`COSE: expected kty=2 (EC2), got ${kty}`); + if (alg !== -7) throw new Error(`COSE: expected alg=-7 (ES-256), got ${alg}`); + + const x = Buffer.from(coseKey[-2]); // 32 bytes + const y = Buffer.from(coseKey[-3]); // 32 bytes + if (x.length !== 32 || y.length !== 32) throw new Error('COSE: invalid P-256 point length'); + + // Build DER SPKI for P-256: + // SEQUENCE { + // SEQUENCE { OID 1.2.840.10045.2.1 (ecPublicKey), OID 1.2.840.10045.3.1.7 (P-256) } + // BIT STRING { 0x04 || x || y } + // } + const oid = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex'); + const point = Buffer.concat([Buffer.from([0x04]), x, y]); + const spki = Buffer.concat([oid, point]); + return spki; +} + +// --------------------------------------------------------------------------- +// Registration verification +// --------------------------------------------------------------------------- + +/** + * Verify a WebAuthn registration (attestation) response. + * + * @param {object} params + * @param {string} params.challenge - base64url challenge that was sent to client + * @param {string} params.clientDataJSON - base64url from CredentialCreationResponse + * @param {string} params.attestationObject - base64url from CredentialCreationResponse + * @param {string} params.credentialId - base64url credential ID from response + * + * @returns {{ credentialId: string, spki: string, signCount: number, aaguid: string }} + * credentialId: base64url, spki: base64-encoded DER, signCount, aaguid: hex + */ +export async function verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: rpIdOverride }) { + // 1. Decode and verify clientDataJSON + const clientData = JSON.parse(fromBase64url(clientDataJSON).toString('utf8')); + if (clientData.type !== 'webauthn.create') { + throw new Error('clientData.type must be webauthn.create'); + } + if (clientData.challenge !== challenge) { + throw new Error('Challenge mismatch'); + } + const rpId = getRpId(rpIdOverride); + const expectedOrigins = getExpectedOrigins(rpId); + if (!expectedOrigins.includes(clientData.origin)) { + throw new Error(`Unexpected origin: ${clientData.origin}. Expected one of: ${expectedOrigins.join(', ')}`); + } + + // 2. Parse attestationObject (CBOR) + const attObjBuf = fromBase64url(attestationObject); + const attObj = cborDecode(attObjBuf); + const fmt = attObj['fmt']; + const authDataBuf = Buffer.from(attObj['authData']); + + // Only accept "none" attestation (all passkey managers use this) + if (fmt !== 'none' && fmt !== 'packed' && fmt !== 'fido-u2f') { + // We parse but don't verify attestation statements for non-none formats; + // passkeys always use "none" so this is fine in practice. + } + + // 3. Parse authenticatorData + const authData = parseAuthenticatorData(authDataBuf); + + // 4. Verify rpIdHash + const expectedRpIdHash = crypto.createHash('sha256').update(rpId).digest(); + if (!authData.rpIdHash.equals(expectedRpIdHash)) { + throw new Error('rpIdHash mismatch'); + } + + // 5. Check user presence flag (UP must be set for passkeys) + if (!authData.flags.UP) { + throw new Error('User presence flag not set'); + } + + // 6. Extract credential data + if (!authData.credentialData) { + throw new Error('No attested credential data in authenticatorData'); + } + const { credentialId: credIdBuf, coseKey, aaguid } = authData.credentialData; + + // Verify credentialId matches what the browser sent + const credIdFromAuthData = base64url(credIdBuf); + if (credIdFromAuthData !== credentialId) { + throw new Error('credentialId mismatch between attestation and response'); + } + + // 7. Convert COSE key to SPKI DER + const spkiBuf = coseToSpki(coseKey); + const spki = toBase64(spkiBuf); + + const aaguidHex = Buffer.from(aaguid).toString('hex'); + + return { + credentialId, + spki, + signCount: authData.signCount, + aaguid: aaguidHex + }; +} + +// --------------------------------------------------------------------------- +// Authentication verification +// --------------------------------------------------------------------------- + +/** + * Verify a WebAuthn authentication (assertion) response. + * + * @param {object} params + * @param {string} params.challenge - base64url challenge that was sent to client + * @param {string} params.clientDataJSON - base64url from CredentialAssertionResponse + * @param {string} params.authenticatorData - base64url from CredentialAssertionResponse + * @param {string} params.signature - base64url from CredentialAssertionResponse + * @param {string} params.spki - base64 DER SPKI stored at registration + * @param {number} params.storedSignCount - sign_count stored in DB + * + * @returns {{ newSignCount: number }} updated sign count + */ +export async function verifyAuthentication({ challenge, clientDataJSON, authenticatorData, signature, spki, storedSignCount, rpId: rpIdOverride }) { + // 1. Decode and verify clientDataJSON + const clientData = JSON.parse(fromBase64url(clientDataJSON).toString('utf8')); + if (clientData.type !== 'webauthn.get') { + throw new Error('clientData.type must be webauthn.get'); + } + if (clientData.challenge !== challenge) { + throw new Error('Challenge mismatch'); + } + const rpId = getRpId(rpIdOverride); + const expectedOrigins = getExpectedOrigins(rpId); + if (!expectedOrigins.includes(clientData.origin)) { + throw new Error(`Unexpected origin: ${clientData.origin}`); + } + + // 2. Parse authenticatorData + const authDataBuf = fromBase64url(authenticatorData); + const authData = parseAuthenticatorData(authDataBuf); + + // 3. Verify rpIdHash + const expectedRpIdHash = crypto.createHash('sha256').update(rpId).digest(); + if (!authData.rpIdHash.equals(expectedRpIdHash)) { + throw new Error('rpIdHash mismatch'); + } + + // 4. Check user presence flag + if (!authData.flags.UP) { + throw new Error('User presence flag not set'); + } + + // 5. Verify signature + // sig = ECDSA-SHA256(authData || SHA256(clientDataJSON)) + const clientDataHash = crypto.createHash('sha256').update(fromBase64url(clientDataJSON)).digest(); + const verifyData = Buffer.concat([authDataBuf, clientDataHash]); + const sigBuf = fromBase64url(signature); + const spkiBuf = fromBase64(spki); + + const keyObject = crypto.createPublicKey({ key: spkiBuf, format: 'der', type: 'spki' }); + // Node.js crypto.verify: algorithm must be a string ('SHA256'), not a WebCrypto object. + // For ECDSA the digest algorithm is specified here; the curve is derived from the key. + const valid = crypto.verify('SHA256', verifyData, keyObject, sigBuf); + if (!valid) { + throw new Error('Signature verification failed'); + } + + // 6. Check sign counter (0 means authenticator doesn't support it — skip check) + const newSignCount = authData.signCount; + if (storedSignCount > 0 && newSignCount !== 0 && newSignCount <= storedSignCount) { + throw new Error(`Sign count replay attack detected: stored=${storedSignCount} got=${newSignCount}`); + } + + return { newSignCount }; +} + +/** + * Build registration options to send to the client. + * @param {object} params + * @param {string} params.challenge - base64url challenge + * @param {string} params.userId - base64url user handle (should be opaque, e.g. SHA256 of user.id) + * @param {string} params.userName - e.g. "anon_abc123" + * @param {string} params.displayName - e.g. "Anonymous" + * @param {Array} params.excludeCredentials - list of {id, type} to exclude (prevent re-registration) + * @returns {object} PublicKeyCredentialCreationOptions-compatible JSON + */ +export function buildRegistrationOptions({ challenge, userId, userName, displayName, excludeCredentials = [], rpId: rpIdOverride }) { + const rpId = getRpId(rpIdOverride); + return { + rp: { + name: cfg.main?.sitename || 'f0ck.dev', + id: rpId + }, + user: { + id: userId, + name: userName, + displayName: displayName || userName + }, + challenge, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 } // ES-256 + ], + timeout: 60000, + excludeCredentials, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred' + }, + attestation: 'none' + }; +} + +/** + * Build authentication options to send to the client. + * @param {object} params + * @param {string} params.challenge - base64url challenge + * @param {Array} params.allowCredentials - list of {id, type} (empty = discoverable / any) + * @returns {object} PublicKeyCredentialRequestOptions-compatible JSON + */ +export function buildAuthenticationOptions({ challenge, allowCredentials = [], rpId: rpIdOverride }) { + const rpId = getRpId(rpIdOverride); + return { + challenge, + rpId, + allowCredentials, + userVerification: 'preferred', + timeout: 60000 + }; +} diff --git a/src/index.mjs b/src/index.mjs index 0a95190..83db888 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -11,6 +11,7 @@ import flummpress from "flummpress"; import { handleUpload } from "./upload_handler.mjs"; import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs"; import { handleBannerUpload, handleBannerDelete } from "./banner_handler.mjs"; +import { handleBrandImageUpload, handleBrandImageDelete } from "./brand_image_handler.mjs"; import { handleRethumbUpload } from "./rethumb_handler.mjs"; import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs"; import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs"; @@ -20,14 +21,13 @@ 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, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes } 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, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } 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"; import security from "./inc/security.mjs"; import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs"; -import { verifySignature } from "./inc/anon_auth.mjs"; import { createRequire } from 'module'; const _require = createRequire(import.meta.url); @@ -120,6 +120,16 @@ function getGateLoginInjection(req) { +
+
+ or +
+
+

No account? Register

@@ -273,6 +283,85 @@ function getGateLoginInjection(req) { if (_gateRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} } }); }; + // Passkey sign-in button (works for both registered users and anonymous passkey holders) + var passkeyBtn = document.getElementById('gate-passkey-btn'); + if (passkeyBtn && window.PublicKeyCredential) { + passkeyBtn.addEventListener('click', async function() { + gateSetError('gate-login-error', ''); + passkeyBtn.disabled = true; + passkeyBtn.style.opacity = '0.65'; + try { + // 1. Get challenge from server + var beginRes = await fetch('/api/v2/anon/passkey/auth/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + var beginData = await beginRes.json(); + if (!beginData.success) throw new Error(beginData.msg || 'Failed to begin passkey authentication'); + var rawChallenge = beginData.options.challenge; // save original base64url string for finish + var opts = beginData.options; + + // 2. Decode base64url fields for WebAuthn API + function b64urlToArr(b64) { + var bin = atob(b64.replace(/-/g,'+').replace(/_/g,'/')); + var arr = new Uint8Array(bin.length); + for (var i=0; i { } }); + // Block all comment-related routes when enable_comments is disabled + app.use(async (req, res) => { + if (cfg.enable_comments !== false) return; + const p = req.url?.pathname || ''; + const isCommentRoute = + /^\/api\/comments/.test(p) || + /^\/api\/comment\//.test(p) || + /^\/api\/subscribe\//.test(p) || + /^\/api\/subscriptions/.test(p) || + /^\/subscriptions(\/|$)/.test(p) || + /^\/ajax\/subscriptions(\/|$)/.test(p) || + /^\/api\/polls\//.test(p) || + /^\/activity(\/|$)/.test(p) || + /^\/user\/[^/]+\/comments/.test(p) || + /^\/api\/v2\/comments/.test(p) || + /^\/api\/v2\/user\/subscribe-all-uploads/.test(p); + if (isCommentRoute) { + res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ success: false, message: "Comments are disabled" })); + req.url.pathname = '/comments_disabled_bypass'; + } + }); + // Global CORS & OPTIONS preflight handler for API routes (enables standalone config_editor.html) app.use(async (req, res) => { if (req.url?.pathname?.startsWith('/api/')) { @@ -1302,20 +1413,6 @@ process.on('uncaughtException', err => { // CSRF validation helper — used by route handlers and global middleware const validateCsrf = async (req, res) => { if (req.session && req.session.csrf_token) { - // Cryptographically proven requests signed by the client's private Ed25519 key are origin-bound and immune to CSRF - const sshPubkey = req.headers['x-ssh-pubkey']; - const sshTimestamp = parseInt(req.headers['x-ssh-timestamp'], 10); - const sshSig = req.headers['x-ssh-signature']; - if (sshPubkey && sshTimestamp && sshSig && getEnableAnonymousAccess()) { - const now = Date.now(); - if (Math.abs(now - sshTimestamp) <= 300000) { - const message = `anon-auth:${sshTimestamp}:${sshPubkey}`; - if (verifySignature(sshPubkey, message, sshSig)) { - return true; - } - } - } - let token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token; // If header/query token is missing and body is not parsed yet on a non-GET method, parse it now @@ -1340,7 +1437,7 @@ process.on('uncaughtException', err => { // because the session middleware will have completed by the time router callbacks execute. app.use(async (req, res) => { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return; - if (['/login', '/register', '/api/v2/anon/session', '/api/v2/anon/logout', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return; + if (['/login', '/register', '/api/v2/anon/session', '/api/v2/anon/logout', '/api/v2/anon/passkey/register/begin', '/api/v2/anon/passkey/register/finish', '/api/v2/anon/passkey/auth/begin', '/api/v2/anon/passkey/auth/finish', '/api/v2/settings/passkeys/register/begin', '/api/v2/settings/passkeys/register/finish', '/api/v2/settings/passkeys/delete', '/api/v2/settings/passkeys/login/begin', '/api/v2/settings/passkeys/login/finish', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import', '/admin/brand_image/upload', '/admin/brand_image/delete'].includes(req.url.pathname)) return; // DM attachment upload validates CSRF internally if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return; // Hall manager routes are handled by bypass middleware with their own session auth @@ -1385,6 +1482,18 @@ process.on('uncaughtException', err => { } }); + // Bypass middleware for brand image upload/delete (multipart — needs raw body before router) + // CSRF is validated inside handleBrandImageUpload/handleBrandImageDelete after their own session lookups + app.use(async (req, res) => { + if (req.method === 'POST' && req.url.pathname === '/admin/brand_image/upload') { + await handleBrandImageUpload(req, res); + req.url.pathname = '/handled_brand_image_upload_bypass'; + } else if (req.method === 'POST' && req.url.pathname === '/admin/brand_image/delete') { + await handleBrandImageDelete(req, res); + req.url.pathname = '/handled_brand_image_delete_bypass'; + } + }); + // Bypass middleware for banner upload (needs raw body before router consumes it) // CSRF is validated inside handleBannerUpload/handleBannerDelete after their own session lookups app.use(async (req, res) => { @@ -1638,6 +1747,18 @@ process.on('uncaughtException', err => { console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message); } + // Fetch brand_image_url setting (DB overrides config.json — no writes to config.json at runtime) + try { + const biSetting = await db`SELECT value FROM site_settings WHERE key = 'brand_image_url' LIMIT 1`; + if (biSetting.length > 0) { + setBrandImageUrl(biSetting[0].value); + console.log(`[BOOT] Brand image URL loaded from DB: ${getBrandImageUrl()}`); + } else { + console.log(`[BOOT] No brand image URL in DB, using config default: ${getBrandImageUrl()}`); + } + } catch (e) { + console.warn(`[BOOT] Brand image URL fetch failed:`, e.message); + } // Set enable_pdf from config (pure config setting) setEnablePdf(!!cfg.enable_pdf); @@ -1799,6 +1920,7 @@ process.on('uncaughtException', err => { halls_enabled: cfg.websrv.halls_enabled !== false, userhalls_enabled: cfg.websrv.userhalls_enabled !== false, enable_userhall_image_upload: cfg.websrv.enable_userhall_image_upload !== false, + enable_oc: cfg.websrv.enable_oc !== false, abyss_enabled: cfg.websrv.abyss_enabled !== false, smtp_enabled: !!(cfg.smtp && cfg.smtp.enabled && cfg.smtp.mail_reset_password), recaptcha_enabled: !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key), @@ -1822,6 +1944,7 @@ process.on('uncaughtException', err => { default_font: cfg.websrv.default_font || "", site_description: cfg.websrv.description || "The webs dumpster", enable_nsfl: !!cfg.enable_nsfl, + enable_comments: cfg.enable_comments !== false, public_nsfw: !!cfg.websrv.public_nsfw, public_untagged: !!cfg.websrv.public_untagged, onara: !!(cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara), @@ -1876,7 +1999,7 @@ process.on('uncaughtException', err => { return JSON.stringify(cfg.websrv.koepfe || []); } }, - custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []), + custom_brand_images_json: JSON.stringify(getBrandImageUrl() ? [getBrandImageUrl()] : []), allowed_comment_images: cfg.websrv.allowed_comment_images || [], allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []), paths_images: cfg.websrv.paths?.images || '/b', @@ -1997,10 +2120,10 @@ process.on('uncaughtException', err => { globals.is_anonymized = isAnonymized; globals.anon_anonymize = anonAnonymize; - // Random brand image per-render - const brand = cfg.websrv.custom_brand_image; - if (Array.isArray(brand) && brand.length > 0) { - data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)]; + // Brand image per-render — sourced from live in-memory setting (DB-backed, not config.json) + const brandUrl = getBrandImageUrl(); + if (brandUrl) { + data.custom_brand_image = brandUrl; } if (activeReq) { diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index bb4f1e8..b622c02 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -422,9 +422,11 @@ export const handleUpload = async (req, res, self) => { addPrivateItem(itemid, filename, req.session.user); } - try { - await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`; - } catch (err) {} + if (cfg.enable_comments !== false) { + try { + await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`; + } catch (err) {} + } try { await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, manualApproval); @@ -1100,14 +1102,16 @@ export const handleUpload = async (req, res, self) => { } // Automatically subscribe uploader to comment thread - try { - await db` - INSERT INTO comment_subscriptions (user_id, item_id) - VALUES (${req.session.id}, ${itemid}) - ON CONFLICT DO NOTHING - `; - } catch (err) { - console.error('[UPLOAD HANDLER] Failed to auto-subscribe uploader:', err); + if (cfg.enable_comments !== false) { + try { + await db` + INSERT INTO comment_subscriptions (user_id, item_id) + VALUES (${req.session.id}, ${itemid}) + ON CONFLICT DO NOTHING + `; + } catch (err) { + console.error('[UPLOAD HANDLER] Failed to auto-subscribe uploader:', err); + } } // Thumbnail & Coverart diff --git a/views/admin.html b/views/admin.html index bb1c1a4..34c225e 100644 --- a/views/admin.html +++ b/views/admin.html @@ -35,10 +35,37 @@ @endif
- + + +
+ +

Upload a logo to display in the site navbar instead of plain text. Accepted: gif, jpg, png, webp, svg — max 2 MB.

+ +
+ +
+ @if(current_brand_image) + current brand + @else + No image set + @endif +
+ + +
+ + + +
+
+ +

+
@@ -199,6 +226,100 @@ btn.textContent = 'Regenerate All'; } } + + async function uploadBrandImage(input) { + const file = input.files[0]; + if (!file) return; + + const status = document.getElementById('brand-status'); + const removeBtn = document.getElementById('brand-remove-btn'); + status.textContent = 'Uploading…'; + status.style.color = 'var(--accent)'; + + const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}'; + const fd = new FormData(); + fd.append('file', file); + + try { + const res = await fetch('/admin/brand_image/upload', { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': csrfToken + }, + body: fd + }); + const data = await res.json(); + if (!data.success) throw new Error(data.msg || 'Upload failed'); + + status.textContent = '✓ Brand image updated!'; + status.style.color = '#28a745'; + + // Update preview + const wrap = document.getElementById('brand-preview-wrap'); + let preview = document.getElementById('brand-preview'); + if (!preview || preview.tagName !== 'IMG') { + wrap.innerHTML = ''; + preview = document.createElement('img'); + preview.id = 'brand-preview'; + preview.alt = 'current brand'; + preview.style.cssText = 'max-height:48px;max-width:150px;object-fit:contain;'; + wrap.appendChild(preview); + } + preview.src = data.url; + + // Enable remove button + removeBtn.disabled = false; + removeBtn.style.cssText = 'background:rgba(220,53,69,0.15);border:1px solid rgba(220,53,69,0.4);color:#dc3545;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.82em;font-weight:bold;transition:background 0.2s;'; + + setTimeout(() => { status.textContent = ''; }, 3000); + } catch (err) { + status.textContent = 'Error: ' + err.message; + status.style.color = '#d9534f'; + } finally { + input.value = ''; + } + } + + async function removeBrandImage() { + const status = document.getElementById('brand-status'); + const removeBtn = document.getElementById('brand-remove-btn'); + + if (!confirm('Remove the custom brand image? The navbar will revert to text.')) return; + + status.textContent = 'Removing…'; + status.style.color = 'var(--accent)'; + + const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}'; + + try { + const res = await fetch('/admin/brand_image/delete', { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': csrfToken + } + }); + const data = await res.json(); + if (!data.success) throw new Error(data.msg || 'Remove failed'); + + status.textContent = '✓ Brand image removed.'; + status.style.color = '#28a745'; + + // Reset preview + const wrap = document.getElementById('brand-preview-wrap'); + wrap.innerHTML = 'No image set'; + + // Disable remove button + removeBtn.disabled = true; + removeBtn.style.cssText = 'background:rgba(220,53,69,0.05);border:1px solid rgba(220,53,69,0.15);color:#884040;padding:7px 16px;border-radius:4px;cursor:not-allowed;font-size:0.82em;font-weight:bold;'; + + setTimeout(() => { status.textContent = ''; }, 3000); + } catch (err) { + status.textContent = 'Error: ' + err.message; + status.style.color = '#d9534f'; + } + } diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index b2dda1c..7e1c121 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -124,17 +124,43 @@
-
- @if(!is_anonymized){!! item.author_description || '' !!}@endif -
- @if(session) - @if(user_has_favorited) - - @else - - @endif - @endif +
+ @if(session) + @if(user_has_favorited) + + @else + + @endif + + @if(enable_comments) + + @endif + + @if(halls_enabled) + + @endif + @if(can_manage_item) + @if(enable_oc) + + @endif + @if(can_extract_meta) + + @endif + @if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie') + + @endif + @endif + @if(is_mod_or_admin) + + + + @endif + @else + + + @endif +
@if(item.is_oc)OC@endif
@@ -157,40 +183,6 @@ @endif -
- @if(!user_alternative_infobox && session) - @if(user_has_favorited) - - @else - - @endif - @endif - @if(session) - - - - @if(halls_enabled) - - @endif - @if(can_manage_item) - - @if(can_extract_meta) - - @endif - @if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie') - - @endif - @endif - @if(is_mod_or_admin) - - - - @endif - @else - - - @endif -
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl) @@ -244,7 +236,7 @@
- @if(session || !hide_comments_from_public) + @if(enable_comments && (session || !hide_comments_from_public))
@endif + @if(enable_comments) + @endif
diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html index 2f6bd45..7cd4caa 100644 --- a/views/item-partial-modern.html +++ b/views/item-partial-modern.html @@ -13,7 +13,7 @@ @endif - @if(session || !hide_comments_from_public) + @if(enable_comments && (session || !hide_comments_from_public))
+ @if(enable_comments) + @endif @if(can_manage_item) + @if(enable_oc) + @endif @if(is_flash_item) @@ -199,7 +203,9 @@
+ @if(enable_comments) + @endif {{-- RIGHT SIDEBAR: recent activity --}} diff --git a/views/mod/audit.html b/views/mod/audit.html index 14e0c3e..96e4391 100644 --- a/views/mod/audit.html +++ b/views/mod/audit.html @@ -4,6 +4,93 @@

AUDIT LOG

Actions performed by moderators and admins.


+ + +
+ + + + @if(filterAction || filterUser) + ✕ Clear + @endif +
+
@each(logs as entry)
@@ -11,7 +98,10 @@ -
{!! entry.created_at_fmt !!}
+
+
{!! entry.created_at_fmt !!}
+ #{!! entry.id !!} +
@@ -123,6 +213,10 @@ var loading = false; var hasMore = currentPage < totalPages; + // Read active filters so infinite scroll preserves them + var activeFilterAction = '{{ filterAction }}' || ''; + var activeFilterUser = '{{ filterUser }}' || ''; + if (pagination) pagination.style.display = 'none'; window.addEventListener('scroll', function () { @@ -143,7 +237,11 @@ try { var next = currentPage + 1; - var res = await fetch('/mod/audit?page=' + next, { + var url = '/mod/audit?page=' + next; + if (activeFilterAction) url += '&action=' + encodeURIComponent(activeFilterAction); + if (activeFilterUser) url += '&user=' + encodeURIComponent(activeFilterUser); + + var res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); var data = await res.json(); @@ -159,7 +257,10 @@ '' + + '
' + '
' + (log.created_at || '') + '
' + + '#' + (log.id || '') + '' + + '
' + '
' + '
' + '
' + diff --git a/views/mod_reports.html b/views/mod_reports.html index 9c40e8a..163c453 100644 --- a/views/mod_reports.html +++ b/views/mod_reports.html @@ -3,448 +3,264 @@
-
-
-
-

User Reports

-

Review and resolve content flags from the community.

-
+
+
+

User Reports

+

Review and resolve content flags from the community.

-
- - +
-
- - - - - - - - - - - - - - -
IDReporterTargetReasonDateActions
Loading reports...
+
+
Loading reports…
-
+
- +
@include(snippets/footer) - diff --git a/views/notifications.html b/views/notifications.html index cb137c0..7cc0d0b 100644 --- a/views/notifications.html +++ b/views/notifications.html @@ -7,8 +7,12 @@
+ @if(enable_comments) + @else + + @endif
@include(snippets/notifications-list) diff --git a/views/scroller.html b/views/scroller.html index 2c64589..cc4c003 100644 --- a/views/scroller.html +++ b/views/scroller.html @@ -1077,12 +1077,20 @@