gsdf
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
+59
-11
@@ -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', {
|
||||
const fetchHeaders = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
};
|
||||
|
||||
if ((!window.f0ckSession?.user || window.f0ckSession?.is_anon) && window.f0ckAnonSSH) {
|
||||
try {
|
||||
const ident = window.f0ckAnonSSH.getIdentity();
|
||||
if (ident && ident.pubkey) {
|
||||
const ts = Date.now();
|
||||
const msg = `anon-auth:${ts}:${ident.pubkey}`;
|
||||
const sig = await window.f0ckAnonSSH.sign(msg);
|
||||
fetchHeaders['X-SSH-Pubkey'] = ident.pubkey;
|
||||
fetchHeaders['X-SSH-Timestamp'] = String(ts);
|
||||
fetchHeaders['X-SSH-Signature'] = sig;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ANON_COMMENTS] Failed to sign comment:', e);
|
||||
}
|
||||
}
|
||||
|
||||
let res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
headers: fetchHeaders,
|
||||
body: params
|
||||
});
|
||||
|
||||
// Auto-recovery if CSRF token drifted
|
||||
if (res.status === 403) {
|
||||
const errJson = await res.clone().json().catch(() => ({}));
|
||||
if (errJson.msg === 'Invalid CSRF token' || errJson.message === 'Invalid CSRF token') {
|
||||
try {
|
||||
const idRes = await fetch('/api/v2/anon/identity', { credentials: 'same-origin' });
|
||||
const idData = await idRes.json();
|
||||
if (idData && idData.csrf_token) {
|
||||
if (window.f0ckSession) window.f0ckSession.csrf_token = idData.csrf_token;
|
||||
const mCsrf = document.querySelector('meta[name="csrf-token"]');
|
||||
if (mCsrf) mCsrf.content = idData.csrf_token;
|
||||
params.set('csrf_token', idData.csrf_token);
|
||||
fetchHeaders['X-CSRF-Token'] = idData.csrf_token;
|
||||
res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: fetchHeaders,
|
||||
body: params
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status >= 500) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
|
||||
+347
-28
@@ -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 (user) ajaxUrl += `&user=${encodeURIComponent(user)}`;
|
||||
if (isFav) ajaxUrl += `&fav=true`;
|
||||
if (isGuestFavMode) {
|
||||
const guestFavIds = f0ckGuestFavs.get();
|
||||
if (guestFavIds.length === 0) {
|
||||
if (replace && posts) {
|
||||
posts.innerHTML = `<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.
|
||||
if (navigator.vibrate) {
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('.steuerung a')) {
|
||||
navigator.vibrate(30);
|
||||
// ── Steuerung haptic & sound feedback ─────────────────────────────────────────
|
||||
(function() {
|
||||
let _audioCtx = null;
|
||||
|
||||
function playSteuerungClickSound() {
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
if (!_audioCtx) {
|
||||
_audioCtx = new AudioCtx();
|
||||
}
|
||||
if (_audioCtx.state === 'suspended') {
|
||||
_audioCtx.resume();
|
||||
}
|
||||
const t = _audioCtx.currentTime;
|
||||
|
||||
// Master gain for smooth, gentle volume
|
||||
const master = _audioCtx.createGain();
|
||||
master.gain.setValueAtTime(0.15, t);
|
||||
master.connect(_audioCtx.destination);
|
||||
|
||||
// Warm body oscillator: smooth sine wave sweeping down (soft mechanical tactile feel)
|
||||
const osc = _audioCtx.createOscillator();
|
||||
const oscGain = _audioCtx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(420, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(110, t + 0.024);
|
||||
|
||||
oscGain.gain.setValueAtTime(0.0001, t);
|
||||
oscGain.gain.linearRampToValueAtTime(1.0, t + 0.0015);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.024);
|
||||
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(master);
|
||||
|
||||
// Subtle transient click layer: triangle wave with gentle decay
|
||||
const snap = _audioCtx.createOscillator();
|
||||
const snapGain = _audioCtx.createGain();
|
||||
snap.type = 'triangle';
|
||||
snap.frequency.setValueAtTime(1200, t);
|
||||
snap.frequency.exponentialRampToValueAtTime(320, t + 0.008);
|
||||
|
||||
snapGain.gain.setValueAtTime(0.0001, t);
|
||||
snapGain.gain.linearRampToValueAtTime(0.3, t + 0.001);
|
||||
snapGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.009);
|
||||
|
||||
snap.connect(snapGain);
|
||||
snapGain.connect(master);
|
||||
|
||||
osc.start(t);
|
||||
snap.start(t);
|
||||
osc.stop(t + 0.03);
|
||||
snap.stop(t + 0.012);
|
||||
} catch (e) {
|
||||
// AudioContext blocked or unsupported
|
||||
}
|
||||
}
|
||||
|
||||
window.playSteuerungClickSound = playSteuerungClickSound;
|
||||
|
||||
let lastTriggerTime = 0;
|
||||
function triggerSteuerungFeedback(target) {
|
||||
if (!target) return;
|
||||
if (target.style.visibility === 'hidden' || target.getAttribute('href') === '#') return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastTriggerTime < 80) return;
|
||||
lastTriggerTime = now;
|
||||
|
||||
if (navigator.vibrate) {
|
||||
try { navigator.vibrate(25); } catch (e) {}
|
||||
}
|
||||
|
||||
target.classList.add('is-clicked');
|
||||
setTimeout(() => {
|
||||
target.classList.remove('is-clicked');
|
||||
}, 120);
|
||||
|
||||
playSteuerungClickSound();
|
||||
}
|
||||
|
||||
// Pointerdown gives instant tactile audio without waiting for pointerup/click
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
const target = e.target.closest('.steuerung a, .steuerung button, .steuerung [role="button"]');
|
||||
if (target) {
|
||||
triggerSteuerungFeedback(target);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Keyboard navigation / programmatic .click() fallback
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('.steuerung a, .steuerung button, .steuerung [role="button"]');
|
||||
if (target) {
|
||||
triggerSteuerungFeedback(target);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Steuerung icon style: #scrolltobottom smooth scroll ───────────────────────
|
||||
// The alternative icon nav replaces the Zufall link with a down-chevron that
|
||||
@@ -12700,6 +12983,39 @@ document.addEventListener('click', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Title Status Notification Management
|
||||
let titleStatusTimeout = null;
|
||||
function setTitleStatus(statusEl, text, isError = false) {
|
||||
if (!statusEl) return;
|
||||
if (titleStatusTimeout) {
|
||||
clearTimeout(titleStatusTimeout);
|
||||
titleStatusTimeout = null;
|
||||
}
|
||||
if (!text) {
|
||||
statusEl.style.display = 'none';
|
||||
statusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = text;
|
||||
statusEl.style.color = isError ? '#e84040' : '';
|
||||
statusEl.style.display = 'inline';
|
||||
titleStatusTimeout = setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
statusEl.textContent = '';
|
||||
titleStatusTimeout = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function clearTitleStatus(statusEl) {
|
||||
if (!statusEl) return;
|
||||
if (titleStatusTimeout) {
|
||||
clearTimeout(titleStatusTimeout);
|
||||
titleStatusTimeout = null;
|
||||
}
|
||||
statusEl.style.display = 'none';
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
|
||||
// Post & File Info Modal Logic
|
||||
document.addEventListener('click', (e) => {
|
||||
const infoBtn = e.target.closest('#a_info');
|
||||
@@ -12823,7 +13139,6 @@ document.addEventListener('click', (e) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Title text click to edit
|
||||
const titleText = e.target.closest('.item_title_text');
|
||||
if (titleText && !document.body.classList.contains('preview-as-user')) {
|
||||
@@ -12831,6 +13146,8 @@ document.addEventListener('click', (e) => {
|
||||
if (container) {
|
||||
const editWrap = container.querySelector('.info-title-edit-wrap');
|
||||
const input = container.querySelector('#info-title-input');
|
||||
const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status');
|
||||
clearTitleStatus(status);
|
||||
if (editWrap && input) {
|
||||
e.preventDefault();
|
||||
titleText.style.display = 'none';
|
||||
@@ -12851,6 +13168,8 @@ document.addEventListener('click', (e) => {
|
||||
const container = openTitleWrap.closest('.item_title');
|
||||
const textEl = container?.querySelector('.item_title_text');
|
||||
const input = container?.querySelector('#info-title-input');
|
||||
const status = container?.querySelector('#info-title-status') || document.getElementById('info-title-status');
|
||||
clearTitleStatus(status);
|
||||
openTitleWrap.style.display = 'none';
|
||||
if (textEl) textEl.style.display = '';
|
||||
if (input && input.dataset.origVal !== undefined) {
|
||||
@@ -12915,20 +13234,13 @@ document.addEventListener('click', (e) => {
|
||||
window.invalidateItemCache(itemId);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.style.display = 'none';
|
||||
status.textContent = '';
|
||||
}
|
||||
clearTitleStatus(status);
|
||||
|
||||
if (window.flashMessage) {
|
||||
window.flashMessage('Title saved', 2000, 'success');
|
||||
}
|
||||
} else {
|
||||
if (status) {
|
||||
status.textContent = data.msg || 'Error saving title';
|
||||
status.style.color = '#e84040';
|
||||
status.style.display = 'inline';
|
||||
}
|
||||
setTitleStatus(status, data.msg || 'Error saving title', true);
|
||||
if (window.flashMessage) {
|
||||
window.flashMessage('Error while saving Title', 3000, 'error');
|
||||
}
|
||||
@@ -12937,11 +13249,7 @@ document.addEventListener('click', (e) => {
|
||||
.catch(() => {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = origIcon;
|
||||
if (status) {
|
||||
status.textContent = 'Network error';
|
||||
status.style.color = '#e84040';
|
||||
status.style.display = 'inline';
|
||||
}
|
||||
setTitleStatus(status, 'Network error', true);
|
||||
if (window.flashMessage) {
|
||||
window.flashMessage('Error while saving Title', 3000, 'error');
|
||||
}
|
||||
@@ -13093,6 +13401,8 @@ document.addEventListener('click', (e) => {
|
||||
const container = e.target.closest('.item_title') || document;
|
||||
const editWrap = container.querySelector('.info-title-edit-wrap');
|
||||
const textEl = container.querySelector('.item_title_text');
|
||||
const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status');
|
||||
clearTitleStatus(status);
|
||||
const input = e.target;
|
||||
if (editWrap) editWrap.style.display = 'none';
|
||||
if (textEl) textEl.style.display = '';
|
||||
@@ -13103,6 +13413,15 @@ document.addEventListener('click', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Clear title error/status as soon as user types into title input
|
||||
document.addEventListener('input', (e) => {
|
||||
if (e.target && e.target.id === 'info-title-input') {
|
||||
const container = e.target.closest('.item_title') || document;
|
||||
const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status');
|
||||
clearTitleStatus(status);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure any navigation event restores the scroll state
|
||||
window.addEventListener('pjax:start', () => {
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
|
||||
+12
-5
@@ -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>
|
||||
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
||||
</button>` : ''}
|
||||
${(() => {
|
||||
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 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -1412,8 +1413,53 @@ export default router => {
|
||||
favs
|
||||
});
|
||||
});
|
||||
|
||||
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.loggedin, async (req, res) => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(',') : '';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;">×</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----- ... -----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;">
|
||||
|
||||
Reference in New Issue
Block a user