This commit is contained in:
2026-09-12 10:10:34 +02:00
parent 217f1be72d
commit 53055ea5c6
35 changed files with 2261 additions and 138 deletions
+2
View File
@@ -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": [
+12
View File
@@ -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);
+89 -2
View File
@@ -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;
+776
View File
@@ -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();
}
})();
+58 -10
View File
@@ -1353,7 +1353,7 @@ class CommentSystem {
} else if (currentUserId) {
inputSection = this.renderInput();
} else {
inputSection = '<div class="login-placeholder"><a href="/login" class="login-trigger-btn">Login</a> to comment</div>';
inputSection = '<div class="login-placeholder"></div>';
}
const isLegacy = document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view');
@@ -2237,15 +2237,22 @@ class CommentSystem {
? `<div class="comment-avatar"><img src="/a/default.png"></div>`
: `<div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div>`;
const authorHtml = isAnonGuest
? `<span class="comment-author">anonymous</span>`
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
? ` <span class="anon-tripcode" data-fingerprint="${this.escapeHtml(anonFp)}" tooltip="${this.escapeHtml(anonFp)} (OpenSSH Ed25519)">[${this.escapeHtml(anonShortFp)}]</span>`
: '';
const authorHtml = isAnon
? `<span class="comment-author">anonymous</span>${anonTripcodeHtml}`
: (comment.username
? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>`
: '<span class="comment-author">System</span>');
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 `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}>${avatarHtml}<div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" ${anonDataAttrs}>${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, isAnonGuest ? null : comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted ? `${currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button>` : ''}<button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" ${anonDataAttrs}>#${comment.id}</a></div>${repliesHtml}`;
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}>${avatarHtml}<div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" ${anonDataAttrs}>${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, isAnon ? null : comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted ? `${(currentUserId || window.f0ckAnonSSH?.pubkey) ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button>` : ''}<button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" ${anonDataAttrs}>#${comment.id}</a></div>${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', {
method: 'POST',
headers: {
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: 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}`);
+343 -24
View File
@@ -57,6 +57,129 @@ window.cancelAnimFrame = (function () {
return null;
};
// <guest-favs> - 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 = `<i class="fa-solid fa-heart" style="color: #ff6b9d; margin-right: 8px;"></i> ${msg.replace('{count}', `<strong>${count}</strong>`)}`;
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;
// </guest-favs>
// 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 (isGuestFavMode) {
const guestFavIds = f0ckGuestFavs.get();
if (guestFavIds.length === 0) {
if (replace && posts) {
posts.innerHTML = `<div class="private-favs-msg" style="padding: 50px 20px; text-align: center; color: var(--text-muted); font-size: 1.1em;"><i class="fa-regular fa-heart" style="font-size: 2.5em; margin-bottom: 15px; display: block; opacity: 0.5;"></i>${(window.f0ckI18n && window.f0ckI18n.no_favs) || 'No favorites yet'}</div>`;
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);
}
}
});
// </search-overlay>
@@ -12260,15 +12455,103 @@ document.addEventListener('DOMContentLoaded', () => {
})();
// ── Steuerung haptic feedback ─────────────────────────────────────────────────
// Short vibration when tapping .steuerung nav links on mobile.
// ── 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) {
document.addEventListener('touchstart', (e) => {
if (e.target.closest('.steuerung a')) {
navigator.vibrate(30);
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();
+11 -4
View File
@@ -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 ? `
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
${(() => {
const isFaved = !!item.is_faved;
return `
<button class="scroll-btn js-fav-btn${isFaved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
<div class="scroll-btn-icon"><i class="${isFaved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
</button>` : ''}
</button>`;
})()}
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
+15 -6
View File
@@ -1133,7 +1133,7 @@
let recObserver = null;
const renderVideoCard = (video, options = {}) => {
const videoKey = (window.f0ckSession?.enable_item_slugs && video.slug) ? video.slug : video.id;
const videoKey = (window.f0ckSession?.enable_item_slugs !== false && video.slug) ? video.slug : video.id;
const rClass = video.rating_class || 'untagged';
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
@@ -1160,11 +1160,20 @@
let displayTitle = '';
if (video.title && video.title.trim()) {
displayTitle = video.title.trim();
} else if (video.tags && video.tags.length > 0) {
displayTitle = video.tags.map(t => '#' + t).join(' ');
} else {
const typeLabel = isAudio ? 'Audio' : (isVideo ? 'Video' : 'Image');
displayTitle = `${typeLabel} #${video.id}`;
} else if (video.tags) {
const rawTags = Array.isArray(video.tags)
? video.tags
: (typeof video.tags === 'string' ? video.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
const formattedTags = rawTags
.map(t => (typeof t === 'object' && t !== null ? (t.tag || t.name || '') : String(t)).trim())
.filter(Boolean)
.map(t => (t.startsWith('#') ? t : '#' + t));
if (formattedTags.length > 0) {
displayTitle = formattedTags.join(' ');
}
}
if (!displayTitle) {
displayTitle = `${videoKey}`;
}
const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in;
+8
View File
@@ -44,6 +44,10 @@ export const handleAvatarUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
console.log('[AVATAR HANDLER] Authorized:', req.session.user);
@@ -206,6 +210,10 @@ export const handleAvatarDelete = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel
+8
View File
@@ -42,6 +42,10 @@ export const handleBannerUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
console.log('[BANNER HANDLER] Authorized:', req.session.user);
@@ -250,6 +254,10 @@ export const handleBannerDelete = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
// CSRF validation
+192
View File
@@ -0,0 +1,192 @@
import crypto from 'node:crypto';
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:<base64-without-padding>
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;
}
}
/**
* Find or create a shadow user in the database for an anonymous SSH identity.
* @param {string} pubkey
* @param {string} fingerprint
* @returns {Promise<{ userId: number, isNew: boolean }>}
*/
export async function getOrCreateAnonUser(pubkey, fingerprint) {
const normPubkey = pubkey.trim();
const existing = await db`
SELECT user_id FROM anon_identities
WHERE pubkey = ${normPubkey}
LIMIT 1
`;
if (existing.length > 0) {
await db`UPDATE anon_identities SET last_seen = NOW() WHERE pubkey = ${normPubkey}`;
return { userId: existing[0].user_id, isNew: false };
}
// Generate unique shadow username
const shortHash = crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 8);
let baseLogin = `anon_${shortHash}`;
let finalLogin = baseLogin;
let counter = 1;
while (true) {
const check = await db`SELECT id FROM "user" WHERE login = ${finalLogin} LIMIT 1`;
if (check.length === 0) break;
finalLogin = `${baseLogin}_${counter++}`;
}
const userRows = await db`
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated)
VALUES (${finalLogin}, ${finalLogin}, '!', false, false, true)
RETURNING id
`;
const userId = userRows[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name)
VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous')
ON CONFLICT (user_id) DO NOTHING
`;
await db`
INSERT INTO anon_identities (user_id, pubkey, fingerprint)
VALUES (${userId}, ${normPubkey}, ${fingerprint})
ON CONFLICT (pubkey) DO NOTHING
`;
return { userId, isNew: true };
}
/**
* Create a valid session in user_sessions for this anonymous user.
* @param {number} userId
* @param {object} req
* @returns {Promise<{ session: string, csrf_token: string }>}
*/
export async function createAnonSession(userId, req) {
// 1. 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) {
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 (req?.cookies?.session) {
const existingHash = lib.sha256(req.cookies.session);
const existing = await db`
SELECT session, csrf_token FROM user_sessions
WHERE user_id = ${userId} AND session = ${existingHash}
LIMIT 1
`;
if (existing.length > 0) {
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`;
return { session: req.cookies.session, csrf_token: existing[0].csrf_token };
}
}
const session = crypto.randomBytes(32).toString('hex');
const sessionHash = lib.sha256(session);
const csrfToken = crypto.randomBytes(24).toString('hex');
const stamp = ~~(Date.now() / 1e3);
const ip = req?.ip || req?.socket?.remoteAddress || '127.0.0.1';
const ua = req?.headers ? (req.headers['user-agent'] || '') : '';
const sessRecord = {
user_id: userId,
session: sessionHash,
csrf_token: csrfToken,
browser: ua,
created_at: stamp,
last_used: stamp,
last_action: '/anon/session',
kmsi: 1,
ip: ip
};
await db`
INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}
`;
return { session, csrf_token: csrfToken };
}
+52
View File
@@ -4,6 +4,7 @@ import db from "./sql.mjs";
import cfg from "./config.mjs";
import { createI18n } from "./i18n.mjs";
import { getEnableAnonymousAccess } from "./settings.mjs";
@@ -158,6 +159,7 @@ export default new class {
if (env.tag) link.push("tag", encodeURIComponent(env.tag));
if (env.hall) link.push("h", encodeURIComponent(env.hall));
if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
else if (env.type === 'favs') link.push("favs");
let tmp = link.length === 0 ? '/' : link.join('/');
if (!tmp.endsWith('/'))
@@ -375,13 +377,63 @@ export default new class {
body: "401 - Unauthorized"
});
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
if (pathname.startsWith('/api/')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Registered account required" }), type: 'application/json' });
}
return res.redirect('/login');
}
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout' && req.url.pathname !== '/settings') {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
}
return next();
};
// Require a real registered user account (explicitly denies anonymous SSH identities)
async registeredUser(req, res, next) {
if (!req.session) {
return res.reply({
code: 401,
body: "401 - Unauthorized"
});
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
if (pathname.startsWith('/api/')) {
return res.reply({
code: 403,
body: JSON.stringify({ success: false, msg: "Action requires a registered account" }),
type: 'application/json'
});
}
return res.redirect('/login');
}
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
}
return next();
};
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);
}
}
}
if (!req.session) {
return res.reply({
code: 401,
+3
View File
@@ -548,6 +548,9 @@
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
"no_uploads": "Keine Uploads gefunden",
"no_favs": "Keine Favoriten",
"guest_favs_saved": "Du hast {count} Gast-Favoriten auf diesem Gerät gespeichert.",
"sync_guest_favs": "In Account importieren",
"guest_favs_imported": "Favoriten erfolgreich in deinen Account importiert!",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
+3
View File
@@ -553,6 +553,9 @@
"subscribe_uploads_btn": "Subscribe user to uploads",
"no_uploads": "no uploads found",
"no_favs": "no favorites",
"guest_favs_saved": "You have {count} guest favorites saved on this device.",
"sync_guest_favs": "Import to Account",
"guest_favs_imported": "Imported favorites to your account!",
"private_favorites": "private favorites",
"back_to_profile": "Back to Profile",
"ban_modal_title": "Ban User",
+3
View File
@@ -546,6 +546,9 @@
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
"no_uploads": "geen uploads gevonden",
"no_favs": "geen favorieten",
"guest_favs_saved": "Je hebt {count} gastfavorieten opgeslagen op dit apparaat.",
"sync_guest_favs": "Importeren naar account",
"guest_favs_imported": "Favorieten succesvol geïmporteerd naar je account!",
"private_favorites": "privé favorieten",
"back_to_profile": "Terug naar Profiel",
"ban_modal_title": "Gebruiker Bannen",
+3
View File
@@ -547,6 +547,9 @@
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
"no_uploads": "keine Aufladierungen gefunden",
"no_favs": "keine Favoriten",
"guest_favs_saved": "Du hast {count} Kaltgast-Favs auf diesem Gerät rumgammeln.",
"sync_guest_favs": "In Account ballern",
"guest_favs_imported": "Favs erfolgreich ins Konto geballert!",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
+55 -16
View File
@@ -487,7 +487,7 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -513,7 +513,7 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -531,7 +531,7 @@ const f0cklib = {
const totalBefore = Number(countRows[0]?.total_before || 0);
return Math.floor(totalBefore / eps) + 1;
},
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger, ids, total: explicitTotal } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
@@ -543,6 +543,20 @@ const f0cklib = {
}
}
const cleanIds = Array.isArray(ids)
? ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof ids === 'string' ? ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) : null);
if (cleanIds !== null && cleanIds.length === 0) {
return {
success: false,
message: "404 - no uploads found",
items: [],
total: 0
};
}
const idsFilter = (cleanIds && cleanIds.length > 0) ? db`and items.id = ANY(${cleanIds}::int[])` : db``;
const filters = await buildFeedFilters({
rawUser,
rawTag,
@@ -593,20 +607,21 @@ const f0cklib = {
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
let total = getCachedCount(cacheKey);
let total = (explicitTotal !== undefined && explicitTotal !== null) ? Number(explicitTotal) : getCachedCount(cacheKey);
if (total === null) {
const totalRows = await db`
select count(distinct items.id) as total
from items
${fav ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``}
${fav && user ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``}
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -617,7 +632,7 @@ const f0cklib = {
${xdFilter}
`;
total = Number(totalRows[0].total);
if (total > 0) setCachedCount(cacheKey, total);
if (total > 0 && !cleanIds) setCachedCount(cacheKey, total);
}
if (!total || total === 0) {
@@ -639,7 +654,7 @@ const f0cklib = {
const pageIdRows = await db`
select items.id, items.is_pinned
from items
${fav ? db`
${fav && user ? db`
inner join favorites on favorites.item_id = items.id
inner join "user" fav_u on fav_u.id = favorites.user_id
` : db``}
@@ -649,7 +664,8 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -658,8 +674,14 @@ const f0cklib = {
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
${fav ? db`group by items.id, items.is_pinned` : db``}
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
${fav && user ? db`group by items.id, items.is_pinned` : db``}
order by ${
random ? db`random()` : (
(fav && !user && cleanIds && cleanIds.length > 0)
? db`array_position(${cleanIds}::int[], items.id)`
: db`items.is_pinned desc, items.id desc`
)
}
offset ${newerThan ? 0 : offset}
limit ${eps}
`;
@@ -774,7 +796,7 @@ const f0cklib = {
view_mode: fav ? 'favs' : 'uploads'
};
},
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang, ids } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
@@ -786,6 +808,11 @@ const f0cklib = {
}
}
const cleanIds = Array.isArray(ids)
? ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof ids === 'string' ? ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) : null);
const idsFilter = (cleanIds && cleanIds.length > 0) ? db`and items.id = ANY(${cleanIds}::int[])` : db``;
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
@@ -899,8 +926,9 @@ const f0cklib = {
${titleFilter}
${hallFilter}
${userHallFilter}
${fav ? db`and "user"."user" ilike ${user}` : db``}
${fav && user ? db`and "user"."user" ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${idsFilter}
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -1030,7 +1058,7 @@ const f0cklib = {
// Determine the effective mode for optimization check (similar to Random)
const nsfl_id = cfg.nsfl_tag_id || 3;
const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall && (!cleanIds || cleanIds.length === 0);
const baseQuery = (whereClause, orderBy, limit = 1) => {
return db`
@@ -1038,7 +1066,7 @@ const f0cklib = {
from items
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
${fav
${fav && user
? db`inner join favorites on favorites.item_id = items.id inner join "user" on "user".id = favorites.user_id`
: db`left join favorites on favorites.item_id = items.id left join "user" on "user".id = favorites.user_id`
}
@@ -1569,16 +1597,27 @@ const f0cklib = {
COALESCE(c.is_pinned, false) as is_pinned,
c.video_time,
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat,
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count,
ai.fingerprint as anon_fingerprint
FROM comments c
JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id
LEFT JOIN anon_identities ai ON ai.user_id = u.id
WHERE c.item_id = ${numericId} AND c.is_deleted = false
ORDER BY COALESCE(c.is_pinned, false) DESC,
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
`;
for (const c of comments) {
if (c.anon_fingerprint) {
c.is_anon = true;
c.username = 'anonymous';
c.display_name = 'Anonymous';
c.anon_short_fingerprint = c.anon_fingerprint.slice(7, 15);
}
}
// Fetch comment file attachments
if (comments.length > 0) {
const commentIds = comments.map(c => c.id);
+7 -1
View File
@@ -24,6 +24,8 @@ export default (router, tpl) => {
contextUrl = query.fav === 'true'
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
} else if (query.fav === 'true') {
contextUrl = `/favs/${req.params.itemid}`;
}
if (query.mime) {
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
@@ -50,6 +52,7 @@ export default (router, tpl) => {
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
fav: query.fav === 'true',
ids: query.ids || null,
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
@@ -156,8 +159,9 @@ export default (router, tpl) => {
data.uploader.color = null;
}
}
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
@@ -244,6 +248,8 @@ export default (router, tpl) => {
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
fav: query.fav === 'true',
ids: query.ids || null,
total: query.total ? parseInt(query.total, 10) : undefined,
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
+129
View File
@@ -0,0 +1,129 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.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.
*/
group.post(/\/session$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 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 { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint);
const { session, csrf_token } = await createAnonSession(userId, req);
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,
csrf_token: csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Session establishment error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state.
*/
group.get(/\/identity$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ logged_in: false, is_anon: false, disabled: true });
}
if (!req.session) {
return res.json({ logged_in: false, is_anon: false });
}
const rows = await db`
SELECT pubkey, fingerprint, created_at, last_seen
FROM anon_identities
WHERE user_id = ${req.session.id}
LIMIT 1
`;
if (rows.length > 0) {
const fp = rows[0].fingerprint;
return res.json({
logged_in: true,
is_anon: true,
user_id: req.session.id,
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
pubkey: rows[0].pubkey,
csrf_token: req.session.csrf_token
});
}
return res.json({
logged_in: true,
is_anon: false,
user: req.session.user,
user_id: req.session.id,
csrf_token: req.session.csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Identity lookup error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/anon/logout
* Clear anonymous session cookie and remove active session from database.
*/
group.post(/\/logout$/, async (req, res) => {
try {
if (req.session && req.session.sess_id) {
await db`
DELETE FROM user_sessions
WHERE id = ${+req.session.sess_id}
`;
}
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);
return res.json({ success: false, msg: err.message }, 500);
}
});
});
};
+52 -6
View File
@@ -762,6 +762,7 @@ export default router => {
xd_score: item.xd_score,
xd_tier: item.xd_tier,
personalized: !!item.personalized,
tags: item.tags || [],
score: typeof item.score === 'number' ? item.score : (typeof item.rank_score === 'number' ? item.rank_score : (item.xd_score || 0)),
rank_score: item.rank_score ?? item.score ?? 0
}))
@@ -1231,7 +1232,7 @@ export default router => {
// PATCH /api/v2/items/:id/title — set or clear the title for an item
// Allowed by: item owner, moderators, admins
group.patch(/\/items\/(?<id>\d+)\/title$/, lib.loggedin, async (req, res) => {
group.patch(/\/items\/(?<id>\d+)\/title$/, lib.registeredUser, async (req, res) => {
const id = +req.params.id;
if (!id) return res.json({ success: false, msg: 'Invalid item id' }, 400);
@@ -1413,7 +1414,52 @@ export default router => {
});
});
group.post(/\/toggle-oc$/, lib.loggedin, async (req, res) => {
group.post(/\/favorites\/import$/, lib.loggedin, async (req, res) => {
try {
const rawIds = req.post?.ids ?? req.body?.ids;
let ids = [];
if (Array.isArray(rawIds)) {
ids = rawIds.map(Number);
} else if (typeof rawIds === 'string') {
ids = rawIds.split(',').map(Number);
}
ids = ids.filter(n => Number.isInteger(n) && n > 0);
if (ids.length === 0) {
return res.json({ success: true, imported: 0 });
}
// Limit import batch to 500 items max for safety
const sliceIds = ids.slice(0, 500);
// Fetch valid existing items
const validItems = await db`
SELECT id FROM items WHERE id = ANY(${sliceIds}::int[]) AND active = true AND is_deleted = false
`;
const validIds = validItems.map(i => i.id);
let count = 0;
for (const itemId of validIds) {
const inserted = await db`
INSERT INTO favorites (user_id, item_id)
VALUES (${req.session.id}, ${itemId})
ON CONFLICT DO NOTHING
RETURNING item_id
`;
if (inserted.length > 0) {
count++;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: itemId, scoreDelta: 5.0 }).catch(() => {});
}
}
return res.json({ success: true, imported: count });
} catch (err) {
console.error('[FAVORITES_IMPORT_ERROR]', err);
return res.status(500).json({ success: false, message: err.message });
}
});
group.post(/\/toggle-oc$/, lib.registeredUser, async (req, res) => {
const postid = +req.post.postid;
if (!postid) return res.json({ success: false, msg: 'No postid provided' }, 400);
@@ -1495,7 +1541,7 @@ export default router => {
});
});
group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => {
group.post(/\/item\/visibility$/, lib.registeredUser, async (req, res) => {
if (cfg.enable_private_uploads === false && !req.session?.admin) {
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
}
@@ -1551,7 +1597,7 @@ export default router => {
});
});
group.post(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.loggedin, async (req, res) => {
group.post(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.registeredUser, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
@@ -1593,7 +1639,7 @@ export default router => {
}
});
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.loggedin, async (req, res) => {
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.registeredUser, async (req, res) => {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
}
@@ -1638,7 +1684,7 @@ export default router => {
});
});
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.registeredUser, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
+22 -22
View File
@@ -10,7 +10,7 @@ import crypto from 'crypto';
export default router => {
router.group(/^\/api\/v2\/settings/, group => {
group.put(/\/setAvatar/, lib.loggedin, async (req, res) => {
group.put(/\/setAvatar/, lib.registeredUser, async (req, res) => {
if (!req.post.avatar) {
return res.json({
msg: 'no avatar provided',
@@ -46,7 +46,7 @@ export default router => {
});
// Switch to custom avatar (sets avatar ID to 0 so avatar_file is used)
group.put(/\/useCustomAvatar/, lib.loggedin, async (req, res) => {
group.put(/\/useCustomAvatar/, lib.registeredUser, async (req, res) => {
// Check if user has a custom avatar file
const userOpts = (await db`
select avatar_file from user_options where user_id = ${+req.session.id}
@@ -139,7 +139,7 @@ export default router => {
});
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
group.post(/\/link\/token/, lib.loggedin, async (req, res) => {
group.post(/\/link\/token/, lib.registeredUser, async (req, res) => {
// 6-char alphanumeric code
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
@@ -179,7 +179,7 @@ export default router => {
});
// Get linked accounts (Discord & Matrix)
group.get(/\/link\/accounts/, lib.loggedin, async (req, res) => {
group.get(/\/link\/accounts/, lib.registeredUser, async (req, res) => {
try {
const aliases = await db`
SELECT alias, type FROM user_alias
@@ -199,7 +199,7 @@ export default router => {
});
// Unlink account
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
try {
const alias = decodeURIComponent(req.params.alias);
const type = req.params.type;
@@ -225,7 +225,7 @@ export default router => {
// Backward compatibility routes for Discord (Deprecated)
// Discord Token Generation (Redirect to generic)
group.post(/\/discord\/token/, lib.loggedin, async (req, res) => {
group.post(/\/discord\/token/, lib.registeredUser, async (req, res) => {
// Just call the logic inline
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
try {
@@ -238,13 +238,13 @@ export default router => {
});
// Get linked Discord accounts (Legacy)
group.get(/\/discord\/linked/, lib.loggedin, async (req, res) => {
group.get(/\/discord\/linked/, lib.registeredUser, async (req, res) => {
const aliases = await db`SELECT alias FROM user_alias WHERE userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
});
// Unlink Discord account (Legacy)
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
const alias = decodeURIComponent(req.params.alias);
await db`DELETE FROM user_alias WHERE lower(alias) = lower(${alias}) AND userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, msg: 'Account unlinked' }, 200);
@@ -365,7 +365,7 @@ export default router => {
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
group.put(/\/default_upload_visibility/, lib.registeredUser, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
@@ -389,7 +389,7 @@ export default router => {
});
// Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
group.put(/\/username_color/, lib.registeredUser, async (req, res) => {
const { color } = req.post;
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
@@ -420,7 +420,7 @@ export default router => {
});
// Update password
group.put(/\/password/, lib.loggedin, async (req, res) => {
group.put(/\/password/, lib.registeredUser, async (req, res) => {
const { current_password, new_password, new_password_confirm } = req.post;
if (!new_password || !new_password_confirm) {
@@ -461,7 +461,7 @@ export default router => {
});
// Update email
group.put(/\/email/, lib.loggedin, async (req, res) => {
group.put(/\/email/, lib.registeredUser, async (req, res) => {
const { email } = req.post;
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
const cleanEmail = email.trim();
@@ -484,7 +484,7 @@ export default router => {
});
// Update Display Name
group.put(/\/display_name/, lib.loggedin, async (req, res) => {
group.put(/\/display_name/, lib.registeredUser, async (req, res) => {
const { display_name } = req.post;
if (display_name !== undefined && typeof display_name !== 'string') {
@@ -517,7 +517,7 @@ export default router => {
});
// Update Description
group.put(/\/description/, lib.loggedin, async (req, res) => {
group.put(/\/description/, lib.registeredUser, async (req, res) => {
if (!cfg.websrv.enable_profile_description) {
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
}
@@ -553,7 +553,7 @@ export default router => {
});
// Update Font preference
group.put(/\/font/, lib.loggedin, async (req, res) => {
group.put(/\/font/, lib.registeredUser, async (req, res) => {
const { font } = req.post;
// F-023 Security: Validate font against actual files on disk
@@ -846,7 +846,7 @@ export default router => {
// GET /api/v2/settings/api-key
// Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview).
group.get(/\/api-key$/, lib.loggedin, async (req, res) => {
group.get(/\/api-key$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -876,7 +876,7 @@ export default router => {
// POST /api/v2/settings/api-key/regenerate
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
group.post(/\/api-key\/regenerate$/, lib.loggedin, async (req, res) => {
group.post(/\/api-key\/regenerate$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -904,7 +904,7 @@ export default router => {
// DELETE /api/v2/settings/api-key
// Revokes (deletes) the user's API key.
group.delete(/\/api-key$/, lib.loggedin, async (req, res) => {
group.delete(/\/api-key$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -928,7 +928,7 @@ export default router => {
// GET /api/v2/settings/api-key/sharex-config
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
group.get(/\/api-key\/sharex-config$/, lib.loggedin, async (req, res) => {
group.get(/\/api-key\/sharex-config$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.status(403).reply({ body: 'API keys are disabled' });
}
@@ -1005,7 +1005,7 @@ export default router => {
// GET /api/v2/settings/invites
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
group.get(/\/invites$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
@@ -1085,7 +1085,7 @@ export default router => {
// POST /api/v2/settings/invites/create
// Generates a new invite token if eligible and slots remain.
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
group.post(/\/invites\/create$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
@@ -1151,7 +1151,7 @@ export default router => {
// POST /api/v2/settings/invites/delete
// Deletes an unused invite token owned by the calling user.
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
group.post(/\/invites\/delete$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
+3 -3
View File
@@ -234,7 +234,7 @@ export default router => {
router.group(/^\/api\/v2/, group => {
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.loggedin, (req, res) => {
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.registeredUser, (req, res) => {
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
const state = progressMap.get(jobId);
res.setHeader?.('Cache-Control', 'no-store');
@@ -307,7 +307,7 @@ export default router => {
return [...new Set(tags)];
};
group.get(/\/meta\/extract-url$/, lib.loggedin, async (req, res) => {
group.get(/\/meta\/extract-url$/, lib.registeredUser, async (req, res) => {
const url = req.url.qs?.url;
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
@@ -358,7 +358,7 @@ export default router => {
}
});
group.post(/\/upload-url$/, lib.loggedin, async (req, res) => {
group.post(/\/upload-url$/, lib.registeredUser, async (req, res) => {
try {
if (!cfg.websrv.web_url_upload) {
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
+33 -5
View File
@@ -7,6 +7,8 @@ import audit from "../audit.mjs";
import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } from "../anon_auth.mjs";
import { getEnableAnonymousAccess } from "../settings.mjs";
export default (router, tpl) => {
@@ -389,6 +391,26 @@ 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);
}
}
}
}
}
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
// Rate limit regular users (admins and mods are exempt)
@@ -627,10 +649,13 @@ export default (router, tpl) => {
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
created_at: new Date().toISOString(),
username_color: req.session.username_color,
display_name: req.session.display_name || null,
display_name: req.session.is_anon ? 'Anonymous' : (req.session.display_name || null),
xd_score: xdRow?.xd_score ?? null,
video_time: newComment[0]?.video_time ?? null,
files: activityFiles
files: activityFiles,
is_anon: !!req.session.is_anon,
anon_fingerprint: req.session.fingerprint || null,
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
};
// 1. Thread live update
@@ -656,11 +681,14 @@ export default (router, tpl) => {
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
username: req.session.user,
username: req.session.is_anon ? 'anonymous' : req.session.user,
username_color: req.session.username_color,
display_name: req.session.display_name || null,
display_name: req.session.is_anon ? 'Anonymous' : (req.session.display_name || null),
files: activityFiles,
is_long: activityIsLong
is_long: activityIsLong,
is_anon: !!req.session.is_anon,
anon_fingerprint: req.session.fingerprint || null,
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
}));
// Automatically subscribe user to the thread
+24 -1
View File
@@ -233,6 +233,12 @@ export default (router, tpl) => {
return res.redirect('/login');
}
// Redirect anonymous users requesting /user/anonymous/favs to their personal shadow username favs
if (req.params.mode === 'favs' && req.params.user?.toLowerCase() === 'anonymous' && req.session?.is_anon && req.session?.login) {
res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` });
return res.end();
}
// Auto-persist strict mode from URL to session if it's there
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
@@ -389,7 +395,8 @@ export default (router, tpl) => {
// Is the current user a moderator/admin?
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
// Can the current user manage this item (owner, admin, or mod)?
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Is the item's MIME type suitable for metadata extraction?
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
@@ -550,6 +557,22 @@ export default (router, tpl) => {
return res.reply({ body });
};
// Favorites route: redirect logged in users (or anon users) to /user/:user/favs, redirect clean guests to /login
router.get(/^\/favs(?:\/p\/(?<page>\d+))?\/?(?:\?.*)?$/, async (req, res) => {
if (req.session && req.session.user) {
const targetUser = (req.session.is_anon && req.session.login) ? req.session.login : req.session.user;
res.writeHead(302, { Location: `/user/${encodeURIComponent(targetUser.toLowerCase())}/favs` });
return res.end();
}
res.writeHead(302, { Location: `/login` });
return res.end();
});
router.get(/^\/favs\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)$/, (req, res) => {
req.params.mode = 'favs';
return handleGenericRoute(req, res);
});
// Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute);
+7 -3
View File
@@ -60,9 +60,9 @@ export default (router, tpl) => {
joined: user?.created_at || null,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
enable_swf: cfg.enable_swf,
enable_data_export: cfg.websrv.enable_data_export,
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: cfg.websrv.enable_user_invites !== false,
enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export,
enable_user_api_keys: !req.session?.is_anon && cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: !req.session?.is_anon && cfg.websrv.enable_user_invites !== false,
site_domain: cfg.main.url.domain,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
@@ -74,6 +74,10 @@ export default (router, tpl) => {
});
});
group.get('/export-data', auth, async (req, res) => {
if (req.session?.is_anon) {
res.status(403).reply({ body: 'Export requires a registered account' });
return;
}
if (!cfg.websrv.enable_data_export) {
res.status(403).reply({ body: 'Export disabled' });
return;
+2 -2
View File
@@ -169,9 +169,9 @@ export default (router, tpl) => {
// Precompute boolean helpers for template @if() — must match index.mjs pattern
if (data.item) {
const session = data.session;
const item = data.item;
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
+5
View File
@@ -30,6 +30,11 @@ export const getEnableItemSlugs = () => {
return true;
};
export const getEnableAnonymousAccess = () => {
if (cfg.enable_anonymous_access === false || cfg.anonymous_access === false || cfg.websrv?.enable_anonymous_access === false || cfg.websrv?.anonymous_access === false) return false;
return true;
};
export const ensureAllItemsHaveSlugs = async () => {
try {
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
+32 -3
View File
@@ -20,13 +20,14 @@ 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, ensureAllItemsHaveSlugs } from "./inc/settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, ensureAllItemsHaveSlugs } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
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);
@@ -830,10 +831,11 @@ process.on('uncaughtException', err => {
user = [_cachedRow];
} else {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode, "anon_identities".fingerprint as anon_fingerprint
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
left join "anon_identities" on "anon_identities".user_id = "user_sessions".user_id
where "user_sessions".session = ${_sessionHash}
limit 1
`;
@@ -851,6 +853,18 @@ process.on('uncaughtException', err => {
}
req.session = user[0];
if (req.session.anon_fingerprint) {
if (!getEnableAnonymousAccess()) {
req.session = false;
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
} else {
req.session.is_anon = true;
req.session.fingerprint = req.session.anon_fingerprint;
req.session.anon_login = user[0].login;
req.session.user = 'anonymous';
req.session.display_name = 'Anonymous';
}
}
// csrf_token is loaded from user_sessions table via the session query above
// Ban check
@@ -1108,6 +1122,20 @@ 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
@@ -1132,7 +1160,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/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/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;
// 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
@@ -1618,6 +1646,7 @@ process.on('uncaughtException', err => {
enable_private_uploads: cfg.enable_private_uploads !== false,
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
get enable_item_slugs() { return getEnableItemSlugs(); },
get enable_anonymous_access() { return getEnableAnonymousAccess(); },
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
nsfl_tag_id: cfg.nsfl_tag_id || 3,
+4
View File
@@ -120,6 +120,10 @@ export const handleUpload = async (req, res, self) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
return sendJson(res, { success: false, msg: 'Uploading requires a registered account' }, 403);
}
// CSRF validation — required for browser sessions, skipped for API key auth.
if (!req.session.api_key_auth) {
const csrfToken = req.headers['x-csrf-token'];
+3 -5
View File
@@ -158,14 +158,14 @@
<div class="gapRight">
@if(session)
@if(!user_alternative_infobox)
@if(!user_alternative_infobox && session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
@if(session)
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@@ -252,12 +252,10 @@
@if(item.is_comments_locked) data-is-locked="true" @endif>
@if(item.is_comments_locked && !is_mod_or_admin)
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
@elseif(session)
@else
<div class="comment-input main-input">
<textarea disabled></textarea>
</div>
@else
<div class="login-placeholder"><a href="/login" class="login-trigger-btn">Login</a> to comment</div>
@endif
</div>
@endif
+3 -3
View File
@@ -21,12 +21,10 @@
@if(item.is_comments_locked) data-is-locked="true" @endif>
@if(item.is_comments_locked && !is_mod_or_admin)
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
@elseif(session)
@else
<div class="comment-input main-input">
<textarea disabled></textarea>
</div>
@else
<div class="login-placeholder"><a href="/login" class="login-trigger-btn">Login</a> to comment</div>
@endif
</div>
@endif
@@ -158,6 +156,8 @@
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
@if(session)
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
+67 -7
View File
@@ -6,19 +6,26 @@
<!-- Quick navigation -->
<nav id="settings-quicknav" aria-label="Settings sections">
@if(!session.is_anon)
<a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
@endif
<a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
@if(enable_data_export)
@if(session.is_anon)
<a href="#anon-ssh"><i class="fa-solid fa-key"></i> SSH Identity</a>
@endif
@if(!session.is_anon && enable_data_export)
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
@endif
@if(!session.is_anon)
<a href="#account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</a>
@if(matrix_enabled || telegram_enabled)
@endif
@if(!session.is_anon && (matrix_enabled || telegram_enabled))
<a href="#linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</a>
@endif
@if(enable_user_api_keys)
@if(!session.is_anon && enable_user_api_keys)
<a href="#apikey"><i class="fa-solid fa-key"></i> API Key</a>
@endif
@if(enable_user_invites)
@if(!session.is_anon && enable_user_invites)
<a href="#invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</a>
@endif
</nav>
@@ -74,6 +81,7 @@
</script>
@if(!session.is_anon)
<!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ -->
<h2 id="profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</h2>
@@ -187,6 +195,7 @@
@endif
</div>
@endif
@@ -510,6 +519,55 @@
</div>
@if(session.is_anon)
<!-- ═══════════════════════════════ ANONYMOUS SSH IDENTITY ═══════════════════════════════ -->
<h2 id="anon-ssh"><i class="fa-solid fa-key"></i> Anonymous SSH Identity</h2>
<div class="account-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p style="color: var(--text-muted); margin-bottom: 16px;">
You are currently browsing anonymously using an OpenSSH Ed25519 keypair. Your favorites and comments are tied to this cryptographic identity without requiring a username, password, or email.
</p>
<div style="margin-bottom: 16px;">
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">OpenSSH SHA256 Fingerprint</label>
<code id="settings-anon-fp" style="background: rgba(0,0,0,0.3); padding: 6px 12px; border-radius: 4px; display: inline-block; color: #5bc0be; font-family: monospace;">{{ session.fingerprint || 'Loading...' }}</code>
</div>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">OpenSSH Public Key</label>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<input type="text" id="settings-anon-pubkey" readonly class="input" style="flex: 1; min-width: 250px; font-family: monospace; font-size: 0.85em;" value="" placeholder="Loading public key...">
<button type="button" class="button" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.copyPublicKey();"><i class="fa-solid fa-copy"></i> Copy</button>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
<button type="button" class="button button-primary" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPrivateKey();"><i class="fa-solid fa-download"></i> Download id_ed25519</button>
<button type="button" class="button" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPublicKey();"><i class="fa-solid fa-download"></i> Download id_ed25519.pub</button>
<button type="button" class="button" onclick="const m = document.getElementById('anon-ssh-modal'); if(m) m.style.display='flex';"><i class="fa-solid fa-key"></i> Import / Supply Key</button>
</div>
<div style="padding: 12px 16px; background: rgba(255,255,255,0.03); border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); font-size: 0.88em; color: var(--text-muted);">
<i class="fa-solid fa-circle-info" style="color: var(--accent); margin-right: 6px;"></i>
<strong>Want full features?</strong> Registered accounts can upload items, customize avatars, create API keys, and invite friends. You can <a href="#" onclick="event.preventDefault(); const m=document.getElementById('register-modal'); if(m) m.style.display='flex';" style="color: var(--accent); text-decoration: underline;">register an account</a> at any time.
</div>
</div>
<script>
(function() {
const syncKey = () => {
if (window.f0ckAnonSSH) {
const pk = document.getElementById('settings-anon-pubkey');
if (pk && window.f0ckAnonSSH.pubkey) pk.value = window.f0ckAnonSSH.pubkey;
const fp = document.getElementById('settings-anon-fp');
if (fp && window.f0ckAnonSSH.fingerprint) fp.textContent = window.f0ckAnonSSH.fingerprint;
}
};
syncKey();
window.addEventListener('f0ck:anon_session_ready', syncKey);
document.addEventListener('DOMContentLoaded', syncKey);
})();
</script>
@endif
@if(enable_data_export)
<!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ -->
<h2 id="export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') || 'Export Data' }}</h2>
@@ -553,6 +611,7 @@
</div>
@endif
@if(!session.is_anon)
<!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ -->
<h2 id="account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</h2>
<div class="account-settings-wrapper"
@@ -612,8 +671,9 @@
</div>
</div>
</div>
@endif
@if(matrix_enabled || telegram_enabled)
@if(!session.is_anon && (matrix_enabled || telegram_enabled))
<!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ -->
<h2 id="linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</h2>
<div class="linked-accounts-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
@@ -650,7 +710,7 @@
</div>
@endif
@if(enable_user_api_keys)
@if(!session.is_anon && enable_user_api_keys)
<!-- ═══════════════════════════════ API KEY ═══════════════════════════════ -->
<h2 id="apikey"><i class="fa-solid fa-key"></i> Upload API Key</h2>
<div id="api-key-section" class="account-settings-wrapper"
@@ -686,7 +746,7 @@
</div>
@endif
@if(enable_user_invites)
@if(!session.is_anon && enable_user_invites)
<!-- ═══════════════════════════════ INVITES ═══════════════════════════════ -->
<h2 id="invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</h2>
<div id="invite-section" class="account-settings-wrapper"
+16 -2
View File
@@ -178,7 +178,7 @@
@endif
@if(private_society && !session)
<script>
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' }}"
};
</script>
<script src="/s/js/anon_ssh.js?v={{ ts }}"></script>
<script src="/s/js/f0ckm.js?v={{ ts }}"></script>
<script src="/s/js/sidebar-activity.js?v={{ ts }}"></script>
<script src="/s/js/flash_yank.js?v={{ ts }}"></script>
+1
View File
@@ -8,6 +8,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
<meta name="csrf-token" content="{{ csrf_token }}">
<script>(function(){var h=document.documentElement;if(localStorage.getItem('hideItemRatings')==='true')h.classList.add('hide-item-ratings-active');if(localStorage.getItem('blurNsfw')==='true')h.classList.add('blur-nsfw-active');if(localStorage.getItem('blurNsfl')==='true')h.classList.add('blur-nsfl-active');if(localStorage.getItem('blurSfw')==='true')h.classList.add('blur-sfw-active');if(localStorage.getItem('blurUntagged')==='true')h.classList.add('blur-untagged-active');if(localStorage.getItem('blurDetail')!=='false')h.classList.add('blur-detail-active');if(localStorage.getItem('imageExpandOnClick')!=='false')h.classList.add('image-expand-active');var ts=localStorage.getItem('scroller_thumb_size');if(ts&&['s','m','l','xl'].includes(ts))h.dataset.thumbSize=ts;})();</script>
<style>
html { background-color: #000; color: #fff; }
+212 -7
View File
@@ -14,7 +14,9 @@
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
<div class="nav-collapse" id="navbarContent">
<div class="nav-links">
@if(!session.is_anon)
<a id="nav-upload-link" style="cursor:pointer;"><i class="fa-solid fa-angle-up"></i> {{ t('nav.upload') }}</a>
@endif
@if(meme_creator)
<a id="nav-meme-link" href="/meme"><i class="fa-regular fa-image"></i> {{ t('nav.meme') }}</a>
@endif
@@ -42,7 +44,9 @@
<!-- Avatar dropdown -->
<div class="nav-user-dropdown">
<button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle">
@if(session.avatar_file)
@if(session.is_anon)
<span class="nav-avatar-icon" style="font-size: 1.2em; margin-right: 6px; display: inline-flex; align-items: center; cursor: pointer;" onclick="event.stopPropagation(); const u = '{{ session.login.toLowerCase() }}'; if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/' + u, true); } else { window.location.href='/user/' + u; }" title="{{ session.login }}"><i class="fa-solid fa-user-secret"></i></span>
@elseif(session.avatar_file)
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
@elseif(session.avatar && session.avatar > 0)
<img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
@@ -53,11 +57,18 @@
<span class="nav-avatar-caret"></span>
</button>
<div class="nav-user-menu" id="nav-user-menu">
@if(session.is_anon)
<a href="/user/{{ session.login.toLowerCase() }}">{{ t('nav.profile') }}</a>
@else
<a href="/user/{!! session.user.toLowerCase() !!}">{{ t('nav.profile') }}</a>
@if(userhalls_enabled)
<a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a>
@endif
<a href="/user/{{ session.user.toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
@endif
@if(enable_anonymous_access && session.is_anon)
<a href="#" id="nav-user-anon-identity-btn" onclick="event.preventDefault(); if(window.f0ckAnonSSH) { window.f0ckAnonSSH.openModal(); } else { const m = document.getElementById('anon-ssh-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-key"></i> Key Management</a>
@endif
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
<div class="nav-user-divider"></div>
<a href="/logout" class="mobile-only">{{ t('nav.logout') }}</a>
@@ -91,10 +102,10 @@
</div>
<!-- Quick Favs -->
<a href="/user/{{ session.user.toLowerCase() }}/favs" title="Favorites" class="desktop-only"><i class="fa-solid fa-heart"></i></a>
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" title="Favorites" class="desktop-only"><i class="fa-solid fa-heart"></i></a>
<!-- DM -->
@if(private_messages)
@if(private_messages && !session.is_anon)
<div id="nav-dm" class="nav-item-rel">
<a href="/messages" id="nav-dm-btn" title="Direct Messages">
<i class="fa-solid fa-envelope"></i>
@@ -261,13 +272,32 @@
<div class="nav-right-group">
<div class="nav-user-dropdown">
<button class="nav-user-btn" id="nav-visitor-toggle"><i class="fa-solid fa-user-secret"></i> {{ t('nav.guest') }} <span class="nav-avatar-caret"></span></button>
<div class="nav-user-menu" id="nav-visitor-menu">
<button class="nav-user-btn" id="nav-visitor-toggle">
<i id="nav-visitor-icon" class="fa-solid @if(enable_anonymous_access && session && session.is_anon) fa-user-secret @else fa-user @endif"></i>
<span id="nav-anon-label">@if(enable_anonymous_access && session && session.is_anon)anonymous@else guest @endif</span>
<span class="nav-avatar-caret"></span>
</button>
<div class="nav-user-menu" id="nav-visitor-menu" style="min-width: max-content; width: max-content; white-space: nowrap;">
@if(enable_anonymous_access && session && session.is_anon)
<a href="/favs" id="nav-guest-favs-link" class="mobile-only">{{ t('nav.favs') }}</a>
@endif
@if(enable_anonymous_access)
<a href="#" id="nav-login-anon-btn" @if(session && session.is_anon) style="display:none;" @endif><i class="fa-solid fa-user-secret"></i> Login as anonymous</a>
<a href="#" id="nav-anon-identity-btn" @if(!session || !session.is_anon) style="display:none;" @endif onclick="event.preventDefault(); if(window.f0ckAnonSSH) { window.f0ckAnonSSH.openModal(); } else { const m = document.getElementById('anon-ssh-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-key"></i> Key Management</a>
<a href="/settings" id="nav-anon-settings-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-gear"></i> Settings</a>
@endif
<a href="#" id="nav-login-btn">{{ t('nav.login') }}</a>
<a href="#" id="nav-register-btn">{{ t('nav.register') }}</a>
<div class="nav-user-divider"></div>
@if(enable_anonymous_access)
<div class="nav-user-divider" id="nav-anon-divider" @if(!session || !session.is_anon) style="display:none;" @endif></div>
<a href="#" id="nav-anon-logout-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-right-from-bracket"></i> Logout (Guest)</a>
@endif
</div>
</div>
@if(enable_anonymous_access && session && session.is_anon)
<!-- Quick Favs for Anon -->
<a href="/favs" id="nav-guest-favs" title="{{ t('nav.favs') }}" class="desktop-only"><i class="fa-solid fa-heart"></i></a>
@endif
<button class="navbar-toggler" type="button" id="nav-toggler"
onclick="document.getElementById('navbarContent').classList.toggle('show'); this.classList.toggle('is-open')">
<span></span><span></span><span></span>
@@ -309,6 +339,13 @@
{{ t('auth.no_account') }} <a href="#" id="login-to-register" style="color: var(--accent); text-decoration: underline;">{{ t('auth.register_now') }}</a>
</p>
@endif
@if(enable_anonymous_access)
<div style="margin-top: 15px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; text-align: center;">
<button type="button" id="modal-login-as-anon-btn" class="btn btn-sm" style="background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #ddd; border-radius: 4px; padding: 7px 14px; font-size: 0.88em; cursor: pointer; width: 100%; transition: background 0.2s;">
<i class="fa-solid fa-user-secret"></i> Login as anonymous
</button>
</div>
@endif
</form>
</div>
@@ -346,6 +383,174 @@
</div>
</div>
@if(enable_anonymous_access)
<!-- Anonymous OpenSSH Ed25519 Identity Modal -->
<div id="anon-ssh-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
<div class="login-modal-content" style="max-width: 540px; width: 92vw; max-height: 90vh; overflow-y: auto; text-align: left; padding: 25px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: var(--bg-primary, #111); box-shadow: 0 10px 40px rgba(0,0,0,0.8);">
<button id="anon-ssh-modal-close" style="position: absolute; top: 15px; right: 15px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.4em; cursor: pointer; line-height: 1;">&times;</button>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<i class="fa-solid fa-key" style="color: var(--accent, #0096ff); font-size: 1.3em;"></i>
<h3 style="margin: 0; font-size: 1.25em; color: var(--text-color, #fff);">Anonymous SSH Identity</h3>
</div>
<p style="margin: 0 0 15px 0; font-size: 0.85em; color: var(--text-muted, #aaa); line-height: 1.4;">
Your browser holds an <strong>OpenSSH Ed25519</strong> private key. Your comments and favorites belong to this key without needing a password.
</p>
<!-- Tabs -->
<div style="display: flex; gap: 8px; margin-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 8px;">
<button type="button" id="anon-tab-btn-identity" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 5px 12px;">My Identity</button>
<button type="button" id="anon-tab-btn-import" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 5px 12px;">Import / Supply Key</button>
</div>
<!-- Tab 1: Current Identity -->
<div id="anon-tab-identity">
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 4px;">Fingerprint</label>
<div style="display: flex; align-items: center; gap: 8px; background: rgba(0,0,0,0.4); padding: 8px 12px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.08);">
<code id="anon-ssh-fp-display" style="font-family: monospace; font-size: 0.88em; color: var(--accent, #00d2ff); word-break: break-all; flex: 1;">Generating...</code>
<button type="button" id="anon-copy-fp-btn" title="Copy Fingerprint" class="btn btn-sm" style="background: transparent; border: none; color: #aaa; cursor: pointer; padding: 4px;"><i class="fa-solid fa-copy"></i></button>
</div>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 4px;">OpenSSH Public Key (<code>id_ed25519.pub</code>)</label>
<textarea id="anon-ssh-pub-display" readonly rows="2" style="width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.82em; background: rgba(0,0,0,0.4); color: #ddd; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; padding: 8px; resize: none;"></textarea>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<button type="button" id="anon-copy-pub-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-copy"></i> Copy Public Key</button>
<button type="button" id="anon-dl-priv-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-download"></i> Download id_ed25519</button>
<button type="button" id="anon-dl-pub-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-download"></i> Download id_ed25519.pub</button>
</div>
</div>
<!-- Tab 2: Import Key -->
<div id="anon-tab-import" style="display: none;">
<p style="font-size: 0.85em; color: var(--text-muted, #aaa); margin-top: 0; margin-bottom: 10px;">
Paste your existing <code>id_ed25519</code> OpenSSH private key or raw 32-byte seed to restore your anonymous identity on this browser.
</p>
<textarea id="anon-import-key-input" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----" style="width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.82em; background: rgba(0,0,0,0.4); color: #ddd; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; padding: 8px; resize: vertical; margin-bottom: 10px;"></textarea>
<input type="file" id="anon-import-file-elem" style="display: none;" />
<div style="display: flex; gap: 8px; align-items: center;">
<button type="button" id="anon-upload-key-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-upload"></i> Upload File</button>
<button type="button" id="anon-submit-import-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 16px;"><i class="fa-solid fa-check"></i> Activate Key</button>
</div>
<div id="anon-import-status" style="margin-top: 10px; font-size: 0.85em; display: none;"></div>
</div>
</div>
</div>
<script>
(function(){
// Tab switching
const tabBtnId = document.getElementById('anon-tab-btn-identity');
const tabBtnImp = document.getElementById('anon-tab-btn-import');
const tabId = document.getElementById('anon-tab-identity');
const tabImp = document.getElementById('anon-tab-import');
if(tabBtnId && tabBtnImp && tabId && tabImp){
tabBtnId.addEventListener('click', function(){
tabId.style.display = 'block';
tabImp.style.display = 'none';
tabBtnId.classList.remove('btn-secondary'); tabBtnId.classList.add('btn-primary');
tabBtnImp.classList.remove('btn-primary'); tabBtnImp.classList.add('btn-secondary');
});
tabBtnImp.addEventListener('click', function(){
tabId.style.display = 'none';
tabImp.style.display = 'block';
tabBtnImp.classList.remove('btn-secondary'); tabBtnImp.classList.add('btn-primary');
tabBtnId.classList.remove('btn-primary'); tabBtnId.classList.add('btn-secondary');
});
}
// Copy buttons
const copyPubBtn = document.getElementById('anon-copy-pub-btn');
if(copyPubBtn){
copyPubBtn.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.copyPublicKey();
});
}
const copyFpBtn = document.getElementById('anon-copy-fp-btn');
if(copyFpBtn){
copyFpBtn.addEventListener('click', function(){
const fp = document.getElementById('anon-ssh-fp-display')?.textContent;
if(fp && navigator.clipboard){
navigator.clipboard.writeText(fp).then(function(){
if(typeof window.showToastNotification === 'function') window.showToastNotification('Fingerprint copied!');
});
}
});
}
// Download buttons
const dlPriv = document.getElementById('anon-dl-priv-btn');
if(dlPriv){
dlPriv.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPrivateKey();
});
}
const dlPub = document.getElementById('anon-dl-pub-btn');
if(dlPub){
dlPub.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPublicKey();
});
}
// File upload trigger
const uploadBtn = document.getElementById('anon-upload-key-btn');
const fileElem = document.getElementById('anon-import-file-elem');
const inputElem = document.getElementById('anon-import-key-input');
if(uploadBtn && fileElem){
uploadBtn.addEventListener('click', function(){ fileElem.click(); });
fileElem.addEventListener('change', function(){
if(fileElem.files && fileElem.files[0]){
const reader = new FileReader();
reader.onload = function(e){
if(inputElem) inputElem.value = e.target.result;
};
reader.readAsText(fileElem.files[0]);
}
});
}
// Submit import
const submitImpBtn = document.getElementById('anon-submit-import-btn');
const statusEl = document.getElementById('anon-import-status');
if(submitImpBtn){
submitImpBtn.addEventListener('click', async function(){
const keyVal = inputElem?.value;
if(!keyVal || !keyVal.trim()){
if(statusEl){ statusEl.style.display = 'block'; statusEl.style.color = '#ff4444'; statusEl.textContent = 'Please paste a key or select a file'; }
return;
}
try {
submitImpBtn.disabled = true;
submitImpBtn.textContent = 'Importing...';
await window.f0ckAnonSSH.importKey(keyVal);
if(statusEl){
statusEl.style.display = 'block';
statusEl.style.color = '#00C851';
statusEl.textContent = 'Key activated successfully! Reloading...';
}
setTimeout(function(){ window.location.reload(); }, 800);
} catch(err){
submitImpBtn.disabled = false;
submitImpBtn.innerHTML = '<i class="fa-solid fa-check"></i> Activate Key';
if(statusEl){
statusEl.style.display = 'block';
statusEl.style.color = '#ff4444';
statusEl.textContent = 'Import error: ' + (err.message || err);
}
}
});
}
})();
</script>
@endif
@if(!private_society || session)
<!-- Shortcuts Modal -->
<div id="shortcuts-modal" style="display: none;">