gsdf
This commit is contained in:
+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;
|
||||
|
||||
Reference in New Issue
Block a user