diff --git a/config_example.json b/config_example.json index 2f0c895..bde5259 100644 --- a/config_example.json +++ b/config_example.json @@ -17,6 +17,7 @@ ], "invite_secret": "YOUR_SECRET_HERE", "hide_comments_from_public": false, + "guest_anonymize": false, "timezone": "UTC", "development": true }, @@ -34,6 +35,7 @@ "default_upload_visibility": 0, "allow_user_upload_visibility": true, "enable_item_slugs": true, + "enable_anonymous_access": true, "onara": false, "nsfl_tag_id": 4, "allowedMimes": [ diff --git a/migrations/add_anon_identities.sql b/migrations/add_anon_identities.sql new file mode 100644 index 0000000..998c783 --- /dev/null +++ b/migrations/add_anon_identities.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS public.anon_identities ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, + pubkey TEXT NOT NULL UNIQUE, + fingerprint TEXT NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_seen TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_anon_identities_pubkey ON public.anon_identities(pubkey); +CREATE INDEX IF NOT EXISTS idx_anon_identities_fingerprint ON public.anon_identities(fingerprint); +CREATE INDEX IF NOT EXISTS idx_anon_identities_user_id ON public.anon_identities(user_id); diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index c4c91a2..2cf7380 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -4782,6 +4782,29 @@ body.layout-legacy .scroll-to-bottom svg { text-overflow: ellipsis; } +.anon-tripcode { + font-family: monospace; + font-size: 0.82em; + font-weight: 600; + color: #00d2ff; + background: rgba(0, 210, 255, 0.08); + border: 1px solid rgba(0, 210, 255, 0.2); + padding: 1px 5px; + border-radius: 3px; + margin-left: 4px; + cursor: help; + user-select: all; + letter-spacing: 0.5px; + display: inline-block; + vertical-align: middle; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.anon-tripcode:hover { + background: rgba(0, 210, 255, 0.18); + border-color: rgba(0, 210, 255, 0.4); +} + .comment-time { margin-right: 5px; font-size: 0.8em; @@ -11681,10 +11704,34 @@ html[theme="f0ck95d"] .badge-dark { .steuerung { font-size: large; font-family: monospace; + user-select: none; + -webkit-user-select: none; } -.steuerung a { +.steuerung a, +.steuerung button { color: white; + display: inline-block; + vertical-align: middle; + text-decoration: none; + cursor: pointer; + transition: color 0.15s ease, transform 0.12s cubic-bezier(0.2, 0, 0, 1), opacity 0.15s ease; +} + +.steuerung a:hover, +.steuerung button:hover { + color: var(--accent); + opacity: 0.85; + transform: translateY(-1px); +} + +.steuerung a:active, +.steuerung button:active, +.steuerung a.is-clicked, +.steuerung button.is-clicked { + transform: scale(0.92) translateY(1px); + opacity: 0.65; + transition-duration: 0.05s; } .steuerung.steuerung-icon { @@ -11698,7 +11745,7 @@ html[theme="f0ck95d"] .badge-dark { align-items: center; justify-content: center; width: 1.6em; - transition: color 0.15s ease, transform 0.15s ease, opacity 0.15s ease; + transition: color 0.15s ease, transform 0.12s cubic-bezier(0.2, 0, 0, 1), opacity 0.15s ease; background: none; border: none; padding: 0; @@ -11709,6 +11756,35 @@ html[theme="f0ck95d"] .badge-dark { .steuerung.steuerung-icon a:hover, .steuerung.steuerung-icon button:hover { color: var(--accent); + transform: translateY(-1px); +} + +.steuerung.steuerung-icon a:active, +.steuerung.steuerung-icon button:active, +.steuerung.steuerung-icon a.is-clicked, +.steuerung.steuerung-icon button.is-clicked { + transform: scale(0.88) translateY(1px); + opacity: 0.65; + transition-duration: 0.05s; +} + +html[theme='light'] .steuerung a, +html[theme='paper'] .steuerung a, +[theme='light'] .steuerung a, +[theme='paper'] .steuerung a { + color: var(--accent); +} + +html[theme='light'] .steuerung a:hover, +html[theme='light'] .steuerung button:hover, +html[theme='paper'] .steuerung a:hover, +html[theme='paper'] .steuerung button:hover, +[theme='light'] .steuerung a:hover, +[theme='light'] .steuerung button:hover, +[theme='paper'] .steuerung a:hover, +[theme='paper'] .steuerung button:hover { + color: var(--accent); + opacity: 0.75; } html[theme='light'] .steuerung.steuerung-icon, @@ -12166,6 +12242,17 @@ body > nav.navbar { z-index: 10000 !important; } +#nav-visitor-menu { + min-width: max-content; + width: max-content; + white-space: nowrap; +} + +#nav-visitor-menu a { + white-space: nowrap; + padding: 7px 14px; +} + .nav-right-group .notif-dropdown, #notif-dropdown { left: auto; diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js new file mode 100644 index 0000000..f266171 --- /dev/null +++ b/public/s/js/anon_ssh.js @@ -0,0 +1,776 @@ +/** + * f0ckm Anonymous OpenSSH Ed25519 Identity Manager + * Provides client-side Ed25519 key generation, OpenSSH key formatting, + * signing, and authentication without login. + */ + +(function () { + 'use strict'; + + const PKCS8_HEADER = new Uint8Array([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20 + ]); + + 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 bytesToBase64(bytes) { + let binary = ''; + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + 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; + } + + function bytesToHex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + 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; + } + + 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 { + constructor() { + this.cryptoKey = null; + this.pubkey = null; + this.fingerprint = null; + this.shortFingerprint = null; + this.rawPub = null; + this.rawSeed = null; + this.isSessionReady = false; + } + + 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); + 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) { + // 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 res = await fetch('/api/v2/anon/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pubkey: this.pubkey, + timestamp: timestamp, + signature: signature + }) + }); + + const data = await res.json(); + if (data.success) { + this.isSessionReady = true; + if (data.csrf_token) { + if (window.f0ckSession) window.f0ckSession.csrf_token = data.csrf_token; + const metaCsrf = document.querySelector('meta[name="csrf-token"]'); + if (metaCsrf) metaCsrf.content = data.csrf_token; + } + window.f0ckAnonIdentity = { + userId: data.user_id, + fingerprint: data.fingerprint, + shortFingerprint: data.short_fingerprint + }; + window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity })); + } + } catch (err) { + console.warn('[ANON_SSH] Session handshake failed:', err); + } + } + + /** + * 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); + 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 (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) { + return; + } + try { + if (!this.pubkey) { + await this.generateIdentity(); + } + await this.ensureSession(true); + // 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(); + } catch (err) { + console.error('[ANON_SSH] Login as anonymous failed:', err); + alert('Failed to log in as anonymous: ' + (err.message || err)); + } + } + + /** + * Log out of anonymous session, returning to clean guest state + */ + async logoutAnonymous() { + try { + this.clearStoredIdentity(); + await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + } finally { + window.location.href = '/'; + } + } + + /** + * Update visitor navbar UI elements + */ + 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'; + } + + 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'; + } + + /** + * Initialize on page startup + */ + async init() { + if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) { + if (window.f0ckSession.is_anon) { + await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + window.location.reload(); + return; + } + this.updateNavUI(false); + return; + } + + // If registered user, do not override + if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) { + 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) { + this.isSessionReady = true; + this.updateNavUI(true); + } else { + await this.ensureSession(); + this.updateNavUI(true); + } + } catch (err) { + console.warn('[ANON_SSH] Failed to restore stored key:', err); + this.clearStoredIdentity(); + this.updateNavUI(false); + } + } else { + // Clean guest state! Do NOT auto-generate or establish session! + if (window.f0ckSession && window.f0ckSession.is_anon) { + // Stale backend anon session cookie without corresponding local key: clear cookie + await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + window.location.reload(); + return; + } + this.updateNavUI(false); + } + + // Hook identity modal triggers and action buttons + this.attachUIListeners(); + } + + attachUIListeners() { + // 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; + } + + const loginTarget = e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon'); + if (loginTarget) { + 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) || this.pubkey)) { + e.preventDefault(); + this.logoutAnonymous(); + return; + } + }); + + const anonBtn = document.getElementById('nav-anon-identity-btn'); + if (anonBtn) { + anonBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.openModal(); + }); + } + + 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(); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => window.f0ckAnonSSH.init()); + } else { + window.f0ckAnonSSH.init(); + } +})(); diff --git a/public/s/js/comments.js b/public/s/js/comments.js index 4ef2511..1442fa2 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -1353,7 +1353,7 @@ class CommentSystem { } else if (currentUserId) { inputSection = this.renderInput(); } else { - inputSection = '
Login to comment
'; + inputSection = '
'; } const isLegacy = document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view'); @@ -2237,15 +2237,22 @@ class CommentSystem { ? `
` : `
${comment.username ? `` : ''}${comment.username ? `` : ''}
`; - const authorHtml = isAnonGuest - ? `anonymous` + const isAnon = isAnonGuest || comment.is_anon || comment.anon_fingerprint || comment.username === 'anonymous' || (comment.username && comment.username.startsWith('anon_')); + const anonFp = comment.anon_fingerprint || ''; + const anonShortFp = comment.anon_short_fingerprint || (anonFp ? anonFp.slice(7, 15) : ''); + const anonTripcodeHtml = anonShortFp + ? ` [${this.escapeHtml(anonShortFp)}]` + : ''; + + const authorHtml = isAnon + ? `anonymous${anonTripcodeHtml}` : (comment.username ? `${this.escapeHtml(comment.display_name || comment.username)}` : 'System'); - const anonDataAttrs = isAnonGuest ? '' : `data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}"`; + const anonDataAttrs = isAnon ? '' : `data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}"`; - return `
${avatarHtml}
${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}
${timeAgo}
${content}
${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, isAnonGuest ? null : comment.username)}
#${comment.id}
${repliesHtml}`; + return `
${avatarHtml}
${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}
${timeAgo}
${content}
${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, isAnon ? null : comment.username)}
#${comment.id}
${repliesHtml}`; } timeAgo(date) { @@ -3461,18 +3468,59 @@ class CommentSystem { params.append('has_poll', '1'); } - const csrfToken = window.f0ckSession?.csrf_token || ''; + let csrfToken = window.f0ckSession?.csrf_token || document.querySelector('meta[name="csrf-token"]')?.content || ''; if (csrfToken) params.append('csrf_token', csrfToken); - const res = await fetch('/api/comments', { + const fetchHeaders = { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }; + + if ((!window.f0ckSession?.user || window.f0ckSession?.is_anon) && window.f0ckAnonSSH) { + try { + const ident = window.f0ckAnonSSH.getIdentity(); + if (ident && ident.pubkey) { + const ts = Date.now(); + const msg = `anon-auth:${ts}:${ident.pubkey}`; + const sig = await window.f0ckAnonSSH.sign(msg); + fetchHeaders['X-SSH-Pubkey'] = ident.pubkey; + fetchHeaders['X-SSH-Timestamp'] = String(ts); + fetchHeaders['X-SSH-Signature'] = sig; + } + } catch (e) { + console.warn('[ANON_COMMENTS] Failed to sign comment:', e); + } + } + + let res = await fetch('/api/comments', { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) - }, + headers: fetchHeaders, body: params }); + // Auto-recovery if CSRF token drifted + if (res.status === 403) { + const errJson = await res.clone().json().catch(() => ({})); + if (errJson.msg === 'Invalid CSRF token' || errJson.message === 'Invalid CSRF token') { + try { + const idRes = await fetch('/api/v2/anon/identity', { credentials: 'same-origin' }); + const idData = await idRes.json(); + if (idData && idData.csrf_token) { + if (window.f0ckSession) window.f0ckSession.csrf_token = idData.csrf_token; + const mCsrf = document.querySelector('meta[name="csrf-token"]'); + if (mCsrf) mCsrf.content = idData.csrf_token; + params.set('csrf_token', idData.csrf_token); + fetchHeaders['X-CSRF-Token'] = idData.csrf_token; + res = await fetch('/api/comments', { + method: 'POST', + headers: fetchHeaders, + body: params + }); + } + } catch (e) {} + } + } + if (!res.ok) { if (res.status >= 500) { throw new Error(`Server returned ${res.status}`); diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 7bbaeda..5fe6f65 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -57,6 +57,129 @@ window.cancelAnimFrame = (function () { return null; }; + // - disabled for clean guest mode + const f0ckGuestFavs = { + get: () => [], + has: () => false, + toggle: () => false, + clear: () => { + try { + localStorage.removeItem('f0ck_guest_favs'); + localStorage.removeItem('guest_favs'); + } catch (e) {} + }, + count: () => 0 + }; + window.f0ckGuestFavs = f0ckGuestFavs; + const syncGuestFavoIcon = () => {}; + window.syncGuestFavoIcon = syncGuestFavoIcon; + + document.addEventListener('click', e => { + if (window.f0ckSession && window.f0ckSession.user) return; + const target = e.target.nodeType === 3 ? e.target.parentElement : e.target; + const favoBtn = target.closest('#a_favo'); + if (!favoBtn) return; + e.preventDefault(); + e.stopPropagation(); + if (typeof window.flashMessage === 'function') { + window.flashMessage('Login to favorite posts'); + } + }); + + const checkGuestFavsImportBanner = () => { + if (!window.f0ckSession || !window.f0ckSession.user) return; + const isUserFavs = window.location.pathname.match(/\/user\/([^/]+)\/favs/); + if (!isUserFavs) return; + const currentUser = window.f0ckSession.user.toLowerCase(); + if (decodeURIComponent(isUserFavs[1]).toLowerCase() !== currentUser) return; + + const count = window.f0ckGuestFavs ? window.f0ckGuestFavs.count() : 0; + if (count <= 0) { + const existing = document.getElementById('guest-favs-import-banner'); + if (existing) existing.remove(); + return; + } + + if (document.getElementById('guest-favs-import-banner')) return; + + const postsContainer = document.querySelector('.posts'); + if (!postsContainer || !postsContainer.parentElement) return; + + const banner = document.createElement('div'); + banner.id = 'guest-favs-import-banner'; + banner.className = 'guest-favs-banner'; + banner.style.cssText = 'background: rgba(255, 107, 157, 0.12); border: 1px solid rgba(255, 107, 157, 0.35); border-radius: 8px; padding: 12px 18px; margin: 15px auto; max-width: 900px; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 0.95em; color: var(--text-color, #fff);'; + + const textSpan = document.createElement('span'); + const msg = (window.f0ckI18n && window.f0ckI18n.guest_favs_saved) || 'You have {count} guest favorites saved on this device.'; + textSpan.innerHTML = ` ${msg.replace('{count}', `${count}`)}`; + + const actionsDiv = document.createElement('div'); + actionsDiv.style.cssText = 'display: flex; gap: 8px; align-items: center; flex-shrink: 0;'; + + const importBtn = document.createElement('button'); + importBtn.id = 'btn-import-guest-favs'; + importBtn.className = 'btn btn-sm'; + importBtn.style.cssText = 'background: #ff6b9d; border: none; border-radius: 4px; padding: 6px 14px; color: white; cursor: pointer; font-weight: 600; font-size: 0.85em; transition: background 0.15s;'; + importBtn.textContent = (window.f0ckI18n && window.f0ckI18n.sync_guest_favs) || 'Import to Account'; + + const dismissBtn = document.createElement('button'); + dismissBtn.id = 'btn-dismiss-guest-favs'; + dismissBtn.className = 'btn btn-sm'; + dismissBtn.style.cssText = 'background: transparent; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 6px 10px; color: #ccc; cursor: pointer; font-size: 0.85em;'; + dismissBtn.textContent = (window.f0ckI18n && window.f0ckI18n.dismiss) || 'Dismiss'; + + importBtn.onclick = async () => { + importBtn.disabled = true; + importBtn.textContent = '...'; + try { + const ids = window.f0ckGuestFavs.get(); + const res = await fetch('/api/v2/favorites/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.f0ckSession?.csrf_token || '' + }, + body: JSON.stringify({ ids }) + }).then(r => r.json()); + + if (res.success) { + window.f0ckGuestFavs.clear(); + banner.remove(); + if (window.flashMessage) { + const succMsg = (window.f0ckI18n && window.f0ckI18n.guest_favs_imported) || 'Imported favorites to your account!'; + window.flashMessage(succMsg); + } + if (typeof window.loadPageAjax === 'function') { + window.loadPageAjax(window.location.pathname, true, { bypassCache: true }); + } else { + window.location.reload(); + } + } else { + importBtn.disabled = false; + importBtn.textContent = (window.f0ckI18n && window.f0ckI18n.sync_guest_favs) || 'Import to Account'; + } + } catch (err) { + importBtn.disabled = false; + importBtn.textContent = (window.f0ckI18n && window.f0ckI18n.sync_guest_favs) || 'Import to Account'; + } + }; + + dismissBtn.onclick = () => { + window.f0ckGuestFavs.clear(); + banner.remove(); + }; + + actionsDiv.appendChild(importBtn); + actionsDiv.appendChild(dismissBtn); + banner.appendChild(textSpan); + banner.appendChild(actionsDiv); + + postsContainer.parentElement.insertBefore(banner, postsContainer); + }; + window.checkGuestFavsImportBanner = checkGuestFavsImportBanner; + // + // OS and Browser detection for CSS targeting const ua = navigator.userAgent; const htmlEl = document.documentElement; @@ -893,8 +1016,8 @@ window.cancelAnimFrame = (function () { if (userToggle && userMenu) { userToggle.addEventListener('click', (e) => { e.stopPropagation(); - if (e.target.closest('.nav-avatar-img')) { - const username = window.f0ckSession?.user; + if (e.target.closest('.nav-avatar-img, .nav-avatar-icon')) { + const username = window.f0ckSession?.is_anon ? (window.f0ckSession?.login || window.f0ckSession?.user) : window.f0ckSession?.user; if (username) { const url = `/user/${username.toLowerCase()}`; if (typeof window.loadPageAjax === 'function') { @@ -3052,6 +3175,7 @@ window.cancelAnimFrame = (function () { const isProfile = !isUserHall && !isUserHalls && pathname.match(/\/user\/([^/?]+)(?:$|\?|$)/) && !pathname.match(/\/user\/[^/]+\/(f0cks|favs|comments|hall|halls)/); const isUserF0cks = pathname.match(/\/user\/([^/?]+)\/f0cks/); const isUserFavs = pathname.match(/\/user\/([^/?]+)\/favs/); + const isGuestFavs = pathname.match(/^\/favs(?:\/|$|\?)/); const isTags = pathname.match(/\/tags\/?(?:$|\?)/); const isComments = pathname.match(/\/user\/([^/?]+)\/comments\/?(?:$|\?)/); const isNotifs = pathname.match(/\/notifications\/?(?:$|\?)/); @@ -3065,7 +3189,8 @@ window.cancelAnimFrame = (function () { const parts = pathname.split('/').filter(Boolean); const isItem = !pathname.match(/\/p\//) && ( pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || - (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) + (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || + (parts.length >= 2 && parts[0] === 'favs' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ); const isMessages = !!pathname.match(/^\/messages(\/|$)/); const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss'; @@ -3266,12 +3391,17 @@ window.cancelAnimFrame = (function () { } const favMatch = url.match(/\/user\/([^/]+)\/favs/); + const guestFavMatch = url.match(/^\/favs(?:\/|$|\?)/); const f0cksMatch = url.match(/\/user\/([^/]+)\/f0cks/); let isFav = false; + let isGuestFavMode = false; if (favMatch) { user = decodeURIComponent(favMatch[1]); isFav = true; + } else if (guestFavMatch && (!window.f0ckSession || !window.f0ckSession.user)) { + isFav = true; + isGuestFavMode = true; } else if (f0cksMatch) { user = decodeURIComponent(f0cksMatch[1]); } @@ -3282,8 +3412,27 @@ window.cancelAnimFrame = (function () { let ajaxUrl = `/ajax/items/?page=${page}&mode=${window.activeMode}`; if (tag) ajaxUrl += `&tag=${encodeURIComponent(tag)}`; if (hall) ajaxUrl += `&hall=${encodeURIComponent(hall)}`; - if (user) ajaxUrl += `&user=${encodeURIComponent(user)}`; - if (isFav) ajaxUrl += `&fav=true`; + if (isGuestFavMode) { + const guestFavIds = f0ckGuestFavs.get(); + if (guestFavIds.length === 0) { + if (replace && posts) { + posts.innerHTML = `
${(window.f0ckI18n && window.f0ckI18n.no_favs) || 'No favorites yet'}
`; + posts.classList.add('show'); + } + const existingPagContainer = document.querySelector('.pagination-container-fluid'); + if (existingPagContainer) existingPagContainer.style.display = 'none'; + if (navbar) navbar.classList.remove('pbwork'); + isNavigating = false; + return; + } + const eps = 24; + const pageNum = parseInt(page, 10) || 1; + const sliceIds = guestFavIds.slice((pageNum - 1) * eps, pageNum * eps); + ajaxUrl = `/ajax/items/?fav=true&ids=${encodeURIComponent(sliceIds.join(','))}&page=${pageNum}&total=${guestFavIds.length}&mode=${window.activeMode}`; + } else { + if (user) ajaxUrl += `&user=${encodeURIComponent(user)}`; + if (isFav) ajaxUrl += `&fav=true`; + } if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`; // Preserve tagger filter from URL query string @@ -3835,6 +3984,7 @@ window.cancelAnimFrame = (function () { // Sync has-notif highlights after grid loads — handles PWA where visibilitychange doesn't fire window.NotificationSystemInstance?.pollDebounced?.(); window._onaraCurrentGridUrl = urlObj.pathname + urlObj.search; + checkGuestFavsImportBanner(); // Instant jump to hash (e.g. #c123) if (hash) { @@ -4098,6 +4248,8 @@ window.cancelAnimFrame = (function () { if (userMatch && !userHall) { user = decodeURIComponent(userMatch[1]); if (url.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true; + } else if (url.match(/^\/favs(\/|$|\?)/)) { + isFavs = true; } const hallMatch = url.match(/\/h\/([^/?]+)/); @@ -4133,6 +4285,8 @@ window.cancelAnimFrame = (function () { if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) { user = decodeURIComponent(wUserMatch[1]); if (window.location.href.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true; + } else if (window.location.href.match(/\/favs(\/|$|\?)/)) { + isFavs = true; } } if (!userHall) { @@ -4164,7 +4318,15 @@ window.cancelAnimFrame = (function () { params.append('user', user); } if (tagger) params.append('tagger', tagger); - if (isFavs) params.append('fav', 'true'); + if (isFavs) { + params.append('fav', 'true'); + if (!user && window.f0ckGuestFavs) { + const guestIds = window.f0ckGuestFavs.get(); + if (guestIds.length > 0) { + params.append('ids', guestIds.slice(0, 100).join(',')); + } + } + } const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); @@ -4312,6 +4474,7 @@ window.cancelAnimFrame = (function () { let _pushUrl = `/${itemKey}`; if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`; else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; } + else if (isFavs) _pushUrl = `/favs/${itemKey}`; else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemKey}`; else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`; if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`; @@ -4564,6 +4727,8 @@ window.cancelAnimFrame = (function () { } else if (user) { pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; + } else if (isFavs) { + pushUrl = `/favs/${itemKey}`; } else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`; else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemKey}`; @@ -4831,6 +4996,15 @@ window.cancelAnimFrame = (function () { const wUserM = window.location.href.match(/\/user\/([^/]+)/); if (wUserM && window.location.href.match(/\/favs(\/|$|\?)/)) { wFavsUser = decodeURIComponent(wUserM[1]); + } else if (window.location.href.match(/\/favs(\/|$|\?)/)) { + const guestIds = window.f0ckGuestFavs ? window.f0ckGuestFavs.get() : []; + if (guestIds.length > 0) { + const currentId = window.getCurrentItemId(); + const candidates = guestIds.filter(id => id !== parseInt(currentId, 10)); + const chosen = candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : guestIds[0]; + loadItemAjax(`/favs/${chosen}`, true, { transition: 'fade-zoom' }); + return; + } } } @@ -5762,6 +5936,10 @@ window.cancelAnimFrame = (function () { if (window.location.pathname.includes('/f0cks')) ctx.f0cks = true; if (window.location.pathname.includes('/tags')) ctx.userTags = true; } + if (window.location.pathname.startsWith('/favs')) { + ctx.fav = true; + if (!window.f0ckSession || !window.f0ckSession.user) ctx.guestFavs = true; + } const mimeMatch = window.location.pathname.match(/\/(image|audio|video)(?:\/|$)/); if (mimeMatch) ctx.mime = mimeMatch[1]; @@ -5958,6 +6136,17 @@ window.cancelAnimFrame = (function () { if (ctx.hall) params.append('hall', ctx.hall); if (ctx.user) params.append('user', ctx.user); if (ctx.fav) params.append('fav', 'true'); + if (ctx.guestFavs && window.f0ckGuestFavs) { + const guestFavIds = window.f0ckGuestFavs.get(); + const eps = 24; + const sliceIds = guestFavIds.slice((nextPage - 1) * eps, nextPage * eps); + if (sliceIds.length === 0) { + infiniteState.hasMore = false; + return; + } + params.append('ids', sliceIds.join(',')); + params.append('total', guestFavIds.length); + } if (ctx.mime) params.append('mime', ctx.mime); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); @@ -6986,6 +7175,12 @@ window.cancelAnimFrame = (function () { initSearch(); initExcludedTagsModal(); if (window.updateFilterBadge) window.updateFilterBadge(); + if (window.location.pathname.startsWith('/favs') && (!window.f0ckSession || !window.f0ckSession.user)) { + const postsEl = document.querySelector('.posts'); + if (postsEl && !postsEl.children.length) { + loadPageAjax(window.location.pathname + window.location.search, true); + } + } }); // @@ -12260,15 +12455,103 @@ document.addEventListener('DOMContentLoaded', () => { })(); -// ── Steuerung haptic feedback ───────────────────────────────────────────────── -// Short vibration when tapping .steuerung nav links on mobile. -if (navigator.vibrate) { - document.addEventListener('touchstart', (e) => { - if (e.target.closest('.steuerung a')) { - navigator.vibrate(30); +// ── Steuerung haptic & sound feedback ───────────────────────────────────────── +(function() { + let _audioCtx = null; + + function playSteuerungClickSound() { + try { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return; + if (!_audioCtx) { + _audioCtx = new AudioCtx(); + } + if (_audioCtx.state === 'suspended') { + _audioCtx.resume(); + } + const t = _audioCtx.currentTime; + + // Master gain for smooth, gentle volume + const master = _audioCtx.createGain(); + master.gain.setValueAtTime(0.15, t); + master.connect(_audioCtx.destination); + + // Warm body oscillator: smooth sine wave sweeping down (soft mechanical tactile feel) + const osc = _audioCtx.createOscillator(); + const oscGain = _audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(420, t); + osc.frequency.exponentialRampToValueAtTime(110, t + 0.024); + + oscGain.gain.setValueAtTime(0.0001, t); + oscGain.gain.linearRampToValueAtTime(1.0, t + 0.0015); + oscGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.024); + + osc.connect(oscGain); + oscGain.connect(master); + + // Subtle transient click layer: triangle wave with gentle decay + const snap = _audioCtx.createOscillator(); + const snapGain = _audioCtx.createGain(); + snap.type = 'triangle'; + snap.frequency.setValueAtTime(1200, t); + snap.frequency.exponentialRampToValueAtTime(320, t + 0.008); + + snapGain.gain.setValueAtTime(0.0001, t); + snapGain.gain.linearRampToValueAtTime(0.3, t + 0.001); + snapGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.009); + + snap.connect(snapGain); + snapGain.connect(master); + + osc.start(t); + snap.start(t); + osc.stop(t + 0.03); + snap.stop(t + 0.012); + } catch (e) { + // AudioContext blocked or unsupported + } + } + + window.playSteuerungClickSound = playSteuerungClickSound; + + let lastTriggerTime = 0; + function triggerSteuerungFeedback(target) { + if (!target) return; + if (target.style.visibility === 'hidden' || target.getAttribute('href') === '#') return; + + const now = Date.now(); + if (now - lastTriggerTime < 80) return; + lastTriggerTime = now; + + if (navigator.vibrate) { + try { navigator.vibrate(25); } catch (e) {} + } + + target.classList.add('is-clicked'); + setTimeout(() => { + target.classList.remove('is-clicked'); + }, 120); + + playSteuerungClickSound(); + } + + // Pointerdown gives instant tactile audio without waiting for pointerup/click + document.addEventListener('pointerdown', (e) => { + const target = e.target.closest('.steuerung a, .steuerung button, .steuerung [role="button"]'); + if (target) { + triggerSteuerungFeedback(target); } }, { passive: true }); -} + + // Keyboard navigation / programmatic .click() fallback + document.addEventListener('click', (e) => { + const target = e.target.closest('.steuerung a, .steuerung button, .steuerung [role="button"]'); + if (target) { + triggerSteuerungFeedback(target); + } + }); +})(); // ── Steuerung icon style: #scrolltobottom smooth scroll ─────────────────────── // The alternative icon nav replaces the Zufall link with a down-chevron that @@ -12700,6 +12983,39 @@ document.addEventListener('click', (e) => { } }); + // Title Status Notification Management + let titleStatusTimeout = null; + function setTitleStatus(statusEl, text, isError = false) { + if (!statusEl) return; + if (titleStatusTimeout) { + clearTimeout(titleStatusTimeout); + titleStatusTimeout = null; + } + if (!text) { + statusEl.style.display = 'none'; + statusEl.textContent = ''; + return; + } + statusEl.textContent = text; + statusEl.style.color = isError ? '#e84040' : ''; + statusEl.style.display = 'inline'; + titleStatusTimeout = setTimeout(() => { + statusEl.style.display = 'none'; + statusEl.textContent = ''; + titleStatusTimeout = null; + }, 4000); + } + + function clearTitleStatus(statusEl) { + if (!statusEl) return; + if (titleStatusTimeout) { + clearTimeout(titleStatusTimeout); + titleStatusTimeout = null; + } + statusEl.style.display = 'none'; + statusEl.textContent = ''; + } + // Post & File Info Modal Logic document.addEventListener('click', (e) => { const infoBtn = e.target.closest('#a_info'); @@ -12823,7 +13139,6 @@ document.addEventListener('click', (e) => { }); return; } - // Title text click to edit const titleText = e.target.closest('.item_title_text'); if (titleText && !document.body.classList.contains('preview-as-user')) { @@ -12831,6 +13146,8 @@ document.addEventListener('click', (e) => { if (container) { const editWrap = container.querySelector('.info-title-edit-wrap'); const input = container.querySelector('#info-title-input'); + const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status'); + clearTitleStatus(status); if (editWrap && input) { e.preventDefault(); titleText.style.display = 'none'; @@ -12851,6 +13168,8 @@ document.addEventListener('click', (e) => { const container = openTitleWrap.closest('.item_title'); const textEl = container?.querySelector('.item_title_text'); const input = container?.querySelector('#info-title-input'); + const status = container?.querySelector('#info-title-status') || document.getElementById('info-title-status'); + clearTitleStatus(status); openTitleWrap.style.display = 'none'; if (textEl) textEl.style.display = ''; if (input && input.dataset.origVal !== undefined) { @@ -12915,20 +13234,13 @@ document.addEventListener('click', (e) => { window.invalidateItemCache(itemId); } - if (status) { - status.style.display = 'none'; - status.textContent = ''; - } + clearTitleStatus(status); if (window.flashMessage) { window.flashMessage('Title saved', 2000, 'success'); } } else { - if (status) { - status.textContent = data.msg || 'Error saving title'; - status.style.color = '#e84040'; - status.style.display = 'inline'; - } + setTitleStatus(status, data.msg || 'Error saving title', true); if (window.flashMessage) { window.flashMessage('Error while saving Title', 3000, 'error'); } @@ -12937,11 +13249,7 @@ document.addEventListener('click', (e) => { .catch(() => { saveBtn.disabled = false; saveBtn.innerHTML = origIcon; - if (status) { - status.textContent = 'Network error'; - status.style.color = '#e84040'; - status.style.display = 'inline'; - } + setTitleStatus(status, 'Network error', true); if (window.flashMessage) { window.flashMessage('Error while saving Title', 3000, 'error'); } @@ -13093,6 +13401,8 @@ document.addEventListener('click', (e) => { const container = e.target.closest('.item_title') || document; const editWrap = container.querySelector('.info-title-edit-wrap'); const textEl = container.querySelector('.item_title_text'); + const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status'); + clearTitleStatus(status); const input = e.target; if (editWrap) editWrap.style.display = 'none'; if (textEl) textEl.style.display = ''; @@ -13103,6 +13413,15 @@ document.addEventListener('click', (e) => { } }); + // Clear title error/status as soon as user types into title input + document.addEventListener('input', (e) => { + if (e.target && e.target.id === 'info-title-input') { + const container = e.target.closest('.item_title') || document; + const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status'); + clearTitleStatus(status); + } + }); + // Ensure any navigation event restores the scroll state window.addEventListener('pjax:start', () => { if (window.resetGlobalScrollState) window.resetGlobalScrollState(); diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index 8020e8f..e76807f 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -1157,6 +1157,10 @@ if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (nowFaved ? 1 : -1)); if (nowFaved) flashFav(slide); } + if (!window.scrollerLoggedIn && (!window.f0ckSession || !window.f0ckSession.user)) { + showShareToast('Login to favorite posts'); + return; + } try { const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || ''; const resp = await fetch('/api/v2/togglefav', { @@ -1392,11 +1396,14 @@ const actions = document.createElement('div'); actions.className = 'scroll-actions'; const _i = window.f0ckI18n || {}; actions.innerHTML = ` - ${window.scrollerLoggedIn ? ` - ` : ''} + ${(() => { + const isFaved = !!item.is_faved; + return ` + `; + })()} + + + +
+ + + +
+ +
+ + Want full features? Registered accounts can upload items, customize avatars, create API keys, and invite friends. You can register an account at any time. +
+ + + @endif + @if(enable_data_export)

{{ t('settings.export_data_title') || 'Export Data' }}

@@ -553,6 +611,7 @@ @endif + @if(!session.is_anon)

{{ t('settings.account') }}

+ @endif - @if(matrix_enabled || telegram_enabled) + @if(!session.is_anon && (matrix_enabled || telegram_enabled))

{{ t('settings.linked_accounts') }}

@@ -650,7 +710,7 @@
@endif - @if(enable_user_api_keys) + @if(!session.is_anon && enable_user_api_keys)

Upload API Key

@endif - @if(enable_user_invites) + @if(!session.is_anon && enable_user_invites)

{{ t('invites.section_title') }}

- window.f0ckSession = { logged_in: false, onara: @if(onara) true @else false @endif, enable_item_slugs: @if(enable_item_slugs) true @else false @endif, hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif, guest_anonymize: @if(guest_anonymize) true @else false @endif, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif, public_untagged: @if(public_untagged) true @else false @endif, public_nsfw: @if(public_nsfw) true @else false @endif }; + window.f0ckSession = { logged_in: false, onara: @if(onara) true @else false @endif, enable_item_slugs: @if(enable_item_slugs) true @else false @endif, enable_anonymous_access: @if(enable_anonymous_access) true @else false @endif, hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif, guest_anonymize: @if(guest_anonymize) true @else false @endif, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif, public_untagged: @if(public_untagged) true @else false @endif, public_nsfw: @if(public_nsfw) true @else false @endif }; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDefaultThumbSize = "{{ default_thumb_size }}"; (() => { @@ -421,6 +421,7 @@ public_untagged: @if(public_untagged) true @else false @endif, public_nsfw: @if(public_nsfw) true @else false @endif, enable_item_slugs: @if(enable_item_slugs) true @else false @endif, + enable_anonymous_access: @if(enable_anonymous_access) true @else false @endif, strict_mode: @if(session && session.strict_mode) true @else false @endif, logged_in: @if(session) true @else false @endif, hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif, @@ -436,6 +437,9 @@ nsfl_tag_id: {{ nsfl_tag_id }}, enable_nsfl: @if(enable_nsfl) true @else false @endif, user: @if(session) "{{ session.user }}" @else null @endif, + login: @if(session) "{{ session.login }}" @else null @endif, + anon_login: @if(session && session.anon_login) "{{ session.anon_login }}" @else null @endif, + is_anon: @if(session && session.is_anon) true @else false @endif, display_name: @if(session && session.display_name) "{!! session.display_name !!}" @else null @endif, username_color: @if(session && session.username_color) "{!! session.username_color !!}" @else null @endif, id: @if(session && session.id) {{ session.id }} @else null @endif, @@ -661,9 +665,19 @@ url_tracker_extracting: "{{ t('upload.url_tracker_extracting') || 'Extracting' }}", url_tracker_complete: "{{ t('upload.url_tracker_complete') || 'Complete!' }}", url_tracker_failed: "{{ t('upload.url_tracker_failed') || 'Upload failed' }}", - url_tracker_view: "{{ t('upload.url_tracker_view') || 'View →' }}" + url_tracker_view: "{{ t('upload.url_tracker_view') || 'View →' }}", + // favorites + fav_added: "{{ t('notifications.fav_added') || 'ADDED TO FAVORITES' }}", + fav_removed: "{{ t('notifications.fav_removed') || 'REMOVED FROM FAVORITES' }}", + no_favs: "{{ t('profile.no_favs') || 'no favorites' }}", + favs_label: "{{ t('profile.favs_label') || 'Favorites' }}", + guest_favs_saved: "{{ t('profile.guest_favs_saved') || 'You have {count} guest favorites saved on this device.' }}", + sync_guest_favs: "{{ t('profile.sync_guest_favs') || 'Import to Account' }}", + guest_favs_imported: "{{ t('profile.guest_favs_imported') || 'Imported favorites to your account!' }}", + dismiss: "{{ t('common.dismiss') || 'Dismiss' }}" }; + diff --git a/views/snippets/header.html b/views/snippets/header.html index cd17e11..9a02edd 100644 --- a/views/snippets/header.html +++ b/views/snippets/header.html @@ -8,6 +8,7 @@ +