gsdf
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
],
|
],
|
||||||
"invite_secret": "YOUR_SECRET_HERE",
|
"invite_secret": "YOUR_SECRET_HERE",
|
||||||
"hide_comments_from_public": false,
|
"hide_comments_from_public": false,
|
||||||
|
"guest_anonymize": false,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"development": true
|
"development": true
|
||||||
},
|
},
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"default_upload_visibility": 0,
|
"default_upload_visibility": 0,
|
||||||
"allow_user_upload_visibility": true,
|
"allow_user_upload_visibility": true,
|
||||||
"enable_item_slugs": true,
|
"enable_item_slugs": true,
|
||||||
|
"enable_anonymous_access": true,
|
||||||
"onara": false,
|
"onara": false,
|
||||||
"nsfl_tag_id": 4,
|
"nsfl_tag_id": 4,
|
||||||
"allowedMimes": [
|
"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;
|
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 {
|
.comment-time {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
@@ -11681,10 +11704,34 @@ html[theme="f0ck95d"] .badge-dark {
|
|||||||
.steuerung {
|
.steuerung {
|
||||||
font-size: large;
|
font-size: large;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.steuerung a {
|
.steuerung a,
|
||||||
|
.steuerung button {
|
||||||
color: white;
|
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 {
|
.steuerung.steuerung-icon {
|
||||||
@@ -11698,7 +11745,7 @@ html[theme="f0ck95d"] .badge-dark {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.6em;
|
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;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -11709,6 +11756,35 @@ html[theme="f0ck95d"] .badge-dark {
|
|||||||
.steuerung.steuerung-icon a:hover,
|
.steuerung.steuerung-icon a:hover,
|
||||||
.steuerung.steuerung-icon button:hover {
|
.steuerung.steuerung-icon button:hover {
|
||||||
color: var(--accent);
|
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,
|
html[theme='light'] .steuerung.steuerung-icon,
|
||||||
@@ -12166,6 +12242,17 @@ body > nav.navbar {
|
|||||||
z-index: 10000 !important;
|
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,
|
.nav-right-group .notif-dropdown,
|
||||||
#notif-dropdown {
|
#notif-dropdown {
|
||||||
left: auto;
|
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) {
|
} else if (currentUserId) {
|
||||||
inputSection = this.renderInput();
|
inputSection = this.renderInput();
|
||||||
} else {
|
} 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');
|
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"><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>`;
|
: `<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
|
const isAnon = isAnonGuest || comment.is_anon || comment.anon_fingerprint || comment.username === 'anonymous' || (comment.username && comment.username.startsWith('anon_'));
|
||||||
? `<span class="comment-author">anonymous</span>`
|
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
|
: (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>`
|
? `<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>');
|
: '<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) {
|
timeAgo(date) {
|
||||||
@@ -3461,18 +3468,59 @@ class CommentSystem {
|
|||||||
params.append('has_poll', '1');
|
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);
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: fetchHeaders,
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
|
||||||
},
|
|
||||||
body: params
|
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.ok) {
|
||||||
if (res.status >= 500) {
|
if (res.status >= 500) {
|
||||||
throw new Error(`Server returned ${res.status}`);
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
|||||||
+347
-28
@@ -57,6 +57,129 @@ window.cancelAnimFrame = (function () {
|
|||||||
return null;
|
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
|
// OS and Browser detection for CSS targeting
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
const htmlEl = document.documentElement;
|
const htmlEl = document.documentElement;
|
||||||
@@ -893,8 +1016,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (userToggle && userMenu) {
|
if (userToggle && userMenu) {
|
||||||
userToggle.addEventListener('click', (e) => {
|
userToggle.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.target.closest('.nav-avatar-img')) {
|
if (e.target.closest('.nav-avatar-img, .nav-avatar-icon')) {
|
||||||
const username = window.f0ckSession?.user;
|
const username = window.f0ckSession?.is_anon ? (window.f0ckSession?.login || window.f0ckSession?.user) : window.f0ckSession?.user;
|
||||||
if (username) {
|
if (username) {
|
||||||
const url = `/user/${username.toLowerCase()}`;
|
const url = `/user/${username.toLowerCase()}`;
|
||||||
if (typeof window.loadPageAjax === 'function') {
|
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 isProfile = !isUserHall && !isUserHalls && pathname.match(/\/user\/([^/?]+)(?:$|\?|$)/) && !pathname.match(/\/user\/[^/]+\/(f0cks|favs|comments|hall|halls)/);
|
||||||
const isUserF0cks = pathname.match(/\/user\/([^/?]+)\/f0cks/);
|
const isUserF0cks = pathname.match(/\/user\/([^/?]+)\/f0cks/);
|
||||||
const isUserFavs = pathname.match(/\/user\/([^/?]+)\/favs/);
|
const isUserFavs = pathname.match(/\/user\/([^/?]+)\/favs/);
|
||||||
|
const isGuestFavs = pathname.match(/^\/favs(?:\/|$|\?)/);
|
||||||
const isTags = pathname.match(/\/tags\/?(?:$|\?)/);
|
const isTags = pathname.match(/\/tags\/?(?:$|\?)/);
|
||||||
const isComments = pathname.match(/\/user\/([^/?]+)\/comments\/?(?:$|\?)/);
|
const isComments = pathname.match(/\/user\/([^/?]+)\/comments\/?(?:$|\?)/);
|
||||||
const isNotifs = pathname.match(/\/notifications\/?(?:$|\?)/);
|
const isNotifs = pathname.match(/\/notifications\/?(?:$|\?)/);
|
||||||
@@ -3065,7 +3189,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
const parts = pathname.split('/').filter(Boolean);
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
const isItem = !pathname.match(/\/p\//) && (
|
const isItem = !pathname.match(/\/p\//) && (
|
||||||
pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
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 isMessages = !!pathname.match(/^\/messages(\/|$)/);
|
||||||
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
|
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
|
||||||
@@ -3266,12 +3391,17 @@ window.cancelAnimFrame = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const favMatch = url.match(/\/user\/([^/]+)\/favs/);
|
const favMatch = url.match(/\/user\/([^/]+)\/favs/);
|
||||||
|
const guestFavMatch = url.match(/^\/favs(?:\/|$|\?)/);
|
||||||
const f0cksMatch = url.match(/\/user\/([^/]+)\/f0cks/);
|
const f0cksMatch = url.match(/\/user\/([^/]+)\/f0cks/);
|
||||||
|
|
||||||
let isFav = false;
|
let isFav = false;
|
||||||
|
let isGuestFavMode = false;
|
||||||
if (favMatch) {
|
if (favMatch) {
|
||||||
user = decodeURIComponent(favMatch[1]);
|
user = decodeURIComponent(favMatch[1]);
|
||||||
isFav = true;
|
isFav = true;
|
||||||
|
} else if (guestFavMatch && (!window.f0ckSession || !window.f0ckSession.user)) {
|
||||||
|
isFav = true;
|
||||||
|
isGuestFavMode = true;
|
||||||
} else if (f0cksMatch) {
|
} else if (f0cksMatch) {
|
||||||
user = decodeURIComponent(f0cksMatch[1]);
|
user = decodeURIComponent(f0cksMatch[1]);
|
||||||
}
|
}
|
||||||
@@ -3282,8 +3412,27 @@ window.cancelAnimFrame = (function () {
|
|||||||
let ajaxUrl = `/ajax/items/?page=${page}&mode=${window.activeMode}`;
|
let ajaxUrl = `/ajax/items/?page=${page}&mode=${window.activeMode}`;
|
||||||
if (tag) ajaxUrl += `&tag=${encodeURIComponent(tag)}`;
|
if (tag) ajaxUrl += `&tag=${encodeURIComponent(tag)}`;
|
||||||
if (hall) ajaxUrl += `&hall=${encodeURIComponent(hall)}`;
|
if (hall) ajaxUrl += `&hall=${encodeURIComponent(hall)}`;
|
||||||
if (user) ajaxUrl += `&user=${encodeURIComponent(user)}`;
|
if (isGuestFavMode) {
|
||||||
if (isFav) ajaxUrl += `&fav=true`;
|
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)}`;
|
if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`;
|
||||||
|
|
||||||
// Preserve tagger filter from URL query string
|
// 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
|
// Sync has-notif highlights after grid loads — handles PWA where visibilitychange doesn't fire
|
||||||
window.NotificationSystemInstance?.pollDebounced?.();
|
window.NotificationSystemInstance?.pollDebounced?.();
|
||||||
window._onaraCurrentGridUrl = urlObj.pathname + urlObj.search;
|
window._onaraCurrentGridUrl = urlObj.pathname + urlObj.search;
|
||||||
|
checkGuestFavsImportBanner();
|
||||||
|
|
||||||
// Instant jump to hash (e.g. #c123)
|
// Instant jump to hash (e.g. #c123)
|
||||||
if (hash) {
|
if (hash) {
|
||||||
@@ -4098,6 +4248,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (userMatch && !userHall) {
|
if (userMatch && !userHall) {
|
||||||
user = decodeURIComponent(userMatch[1]);
|
user = decodeURIComponent(userMatch[1]);
|
||||||
if (url.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
|
if (url.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
|
||||||
|
} else if (url.match(/^\/favs(\/|$|\?)/)) {
|
||||||
|
isFavs = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hallMatch = url.match(/\/h\/([^/?]+)/);
|
const hallMatch = url.match(/\/h\/([^/?]+)/);
|
||||||
@@ -4133,6 +4285,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) {
|
if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) {
|
||||||
user = decodeURIComponent(wUserMatch[1]);
|
user = decodeURIComponent(wUserMatch[1]);
|
||||||
if (window.location.href.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
|
if (window.location.href.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
|
||||||
|
} else if (window.location.href.match(/\/favs(\/|$|\?)/)) {
|
||||||
|
isFavs = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!userHall) {
|
if (!userHall) {
|
||||||
@@ -4164,7 +4318,15 @@ window.cancelAnimFrame = (function () {
|
|||||||
params.append('user', user);
|
params.append('user', user);
|
||||||
}
|
}
|
||||||
if (tagger) params.append('tagger', tagger);
|
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');
|
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
|
||||||
|
|
||||||
@@ -4312,6 +4474,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
let _pushUrl = `/${itemKey}`;
|
let _pushUrl = `/${itemKey}`;
|
||||||
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${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 (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 (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemKey}`;
|
||||||
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`;
|
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`;
|
||||||
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
||||||
@@ -4564,6 +4727,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
} else if (user) {
|
} else if (user) {
|
||||||
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
|
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
|
||||||
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${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 (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`;
|
||||||
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).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\/([^/]+)/);
|
const wUserM = window.location.href.match(/\/user\/([^/]+)/);
|
||||||
if (wUserM && window.location.href.match(/\/favs(\/|$|\?)/)) {
|
if (wUserM && window.location.href.match(/\/favs(\/|$|\?)/)) {
|
||||||
wFavsUser = decodeURIComponent(wUserM[1]);
|
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('/f0cks')) ctx.f0cks = true;
|
||||||
if (window.location.pathname.includes('/tags')) ctx.userTags = 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)(?:\/|$)/);
|
const mimeMatch = window.location.pathname.match(/\/(image|audio|video)(?:\/|$)/);
|
||||||
if (mimeMatch) ctx.mime = mimeMatch[1];
|
if (mimeMatch) ctx.mime = mimeMatch[1];
|
||||||
@@ -5958,6 +6136,17 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (ctx.hall) params.append('hall', ctx.hall);
|
if (ctx.hall) params.append('hall', ctx.hall);
|
||||||
if (ctx.user) params.append('user', ctx.user);
|
if (ctx.user) params.append('user', ctx.user);
|
||||||
if (ctx.fav) params.append('fav', 'true');
|
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);
|
if (ctx.mime) params.append('mime', ctx.mime);
|
||||||
|
|
||||||
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
|
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
|
||||||
@@ -6986,6 +7175,12 @@ window.cancelAnimFrame = (function () {
|
|||||||
initSearch();
|
initSearch();
|
||||||
initExcludedTagsModal();
|
initExcludedTagsModal();
|
||||||
if (window.updateFilterBadge) window.updateFilterBadge();
|
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>
|
// </search-overlay>
|
||||||
|
|
||||||
@@ -12260,15 +12455,103 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Steuerung haptic feedback ─────────────────────────────────────────────────
|
// ── Steuerung haptic & sound feedback ─────────────────────────────────────────
|
||||||
// Short vibration when tapping .steuerung nav links on mobile.
|
(function() {
|
||||||
if (navigator.vibrate) {
|
let _audioCtx = null;
|
||||||
document.addEventListener('touchstart', (e) => {
|
|
||||||
if (e.target.closest('.steuerung a')) {
|
function playSteuerungClickSound() {
|
||||||
navigator.vibrate(30);
|
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 });
|
}, { 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 ───────────────────────
|
// ── Steuerung icon style: #scrolltobottom smooth scroll ───────────────────────
|
||||||
// The alternative icon nav replaces the Zufall link with a down-chevron that
|
// 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
|
// Post & File Info Modal Logic
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const infoBtn = e.target.closest('#a_info');
|
const infoBtn = e.target.closest('#a_info');
|
||||||
@@ -12823,7 +13139,6 @@ document.addEventListener('click', (e) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title text click to edit
|
// Title text click to edit
|
||||||
const titleText = e.target.closest('.item_title_text');
|
const titleText = e.target.closest('.item_title_text');
|
||||||
if (titleText && !document.body.classList.contains('preview-as-user')) {
|
if (titleText && !document.body.classList.contains('preview-as-user')) {
|
||||||
@@ -12831,6 +13146,8 @@ document.addEventListener('click', (e) => {
|
|||||||
if (container) {
|
if (container) {
|
||||||
const editWrap = container.querySelector('.info-title-edit-wrap');
|
const editWrap = container.querySelector('.info-title-edit-wrap');
|
||||||
const input = container.querySelector('#info-title-input');
|
const input = container.querySelector('#info-title-input');
|
||||||
|
const status = container.querySelector('#info-title-status') || document.getElementById('info-title-status');
|
||||||
|
clearTitleStatus(status);
|
||||||
if (editWrap && input) {
|
if (editWrap && input) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
titleText.style.display = 'none';
|
titleText.style.display = 'none';
|
||||||
@@ -12851,6 +13168,8 @@ document.addEventListener('click', (e) => {
|
|||||||
const container = openTitleWrap.closest('.item_title');
|
const container = openTitleWrap.closest('.item_title');
|
||||||
const textEl = container?.querySelector('.item_title_text');
|
const textEl = container?.querySelector('.item_title_text');
|
||||||
const input = container?.querySelector('#info-title-input');
|
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';
|
openTitleWrap.style.display = 'none';
|
||||||
if (textEl) textEl.style.display = '';
|
if (textEl) textEl.style.display = '';
|
||||||
if (input && input.dataset.origVal !== undefined) {
|
if (input && input.dataset.origVal !== undefined) {
|
||||||
@@ -12915,20 +13234,13 @@ document.addEventListener('click', (e) => {
|
|||||||
window.invalidateItemCache(itemId);
|
window.invalidateItemCache(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
clearTitleStatus(status);
|
||||||
status.style.display = 'none';
|
|
||||||
status.textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.flashMessage) {
|
if (window.flashMessage) {
|
||||||
window.flashMessage('Title saved', 2000, 'success');
|
window.flashMessage('Title saved', 2000, 'success');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (status) {
|
setTitleStatus(status, data.msg || 'Error saving title', true);
|
||||||
status.textContent = data.msg || 'Error saving title';
|
|
||||||
status.style.color = '#e84040';
|
|
||||||
status.style.display = 'inline';
|
|
||||||
}
|
|
||||||
if (window.flashMessage) {
|
if (window.flashMessage) {
|
||||||
window.flashMessage('Error while saving Title', 3000, 'error');
|
window.flashMessage('Error while saving Title', 3000, 'error');
|
||||||
}
|
}
|
||||||
@@ -12937,11 +13249,7 @@ document.addEventListener('click', (e) => {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.innerHTML = origIcon;
|
saveBtn.innerHTML = origIcon;
|
||||||
if (status) {
|
setTitleStatus(status, 'Network error', true);
|
||||||
status.textContent = 'Network error';
|
|
||||||
status.style.color = '#e84040';
|
|
||||||
status.style.display = 'inline';
|
|
||||||
}
|
|
||||||
if (window.flashMessage) {
|
if (window.flashMessage) {
|
||||||
window.flashMessage('Error while saving Title', 3000, 'error');
|
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 container = e.target.closest('.item_title') || document;
|
||||||
const editWrap = container.querySelector('.info-title-edit-wrap');
|
const editWrap = container.querySelector('.info-title-edit-wrap');
|
||||||
const textEl = container.querySelector('.item_title_text');
|
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;
|
const input = e.target;
|
||||||
if (editWrap) editWrap.style.display = 'none';
|
if (editWrap) editWrap.style.display = 'none';
|
||||||
if (textEl) textEl.style.display = '';
|
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
|
// Ensure any navigation event restores the scroll state
|
||||||
window.addEventListener('pjax:start', () => {
|
window.addEventListener('pjax:start', () => {
|
||||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
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 (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (nowFaved ? 1 : -1));
|
||||||
if (nowFaved) flashFav(slide);
|
if (nowFaved) flashFav(slide);
|
||||||
}
|
}
|
||||||
|
if (!window.scrollerLoggedIn && (!window.f0ckSession || !window.f0ckSession.user)) {
|
||||||
|
showShareToast('Login to favorite posts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||||
const resp = await fetch('/api/v2/togglefav', {
|
const resp = await fetch('/api/v2/togglefav', {
|
||||||
@@ -1392,11 +1396,14 @@
|
|||||||
const actions = document.createElement('div'); actions.className = 'scroll-actions';
|
const actions = document.createElement('div'); actions.className = 'scroll-actions';
|
||||||
const _i = window.f0ckI18n || {};
|
const _i = window.f0ckI18n || {};
|
||||||
actions.innerHTML = `
|
actions.innerHTML = `
|
||||||
${window.scrollerLoggedIn ? `
|
${(() => {
|
||||||
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
const isFaved = !!item.is_faved;
|
||||||
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
return `
|
||||||
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
<button class="scroll-btn js-fav-btn${isFaved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
||||||
</button>` : ''}
|
<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)">
|
<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>
|
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
|
||||||
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
||||||
|
|||||||
@@ -1133,7 +1133,7 @@
|
|||||||
let recObserver = null;
|
let recObserver = null;
|
||||||
|
|
||||||
const renderVideoCard = (video, options = {}) => {
|
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 rClass = video.rating_class || 'untagged';
|
||||||
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
||||||
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
||||||
@@ -1160,11 +1160,20 @@
|
|||||||
let displayTitle = '';
|
let displayTitle = '';
|
||||||
if (video.title && video.title.trim()) {
|
if (video.title && video.title.trim()) {
|
||||||
displayTitle = video.title.trim();
|
displayTitle = video.title.trim();
|
||||||
} else if (video.tags && video.tags.length > 0) {
|
} else if (video.tags) {
|
||||||
displayTitle = video.tags.map(t => '#' + t).join(' ');
|
const rawTags = Array.isArray(video.tags)
|
||||||
} else {
|
? video.tags
|
||||||
const typeLabel = isAudio ? 'Audio' : (isVideo ? 'Video' : 'Image');
|
: (typeof video.tags === 'string' ? video.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
|
||||||
displayTitle = `${typeLabel} #${video.id}`;
|
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;
|
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);
|
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];
|
req.session = user[0];
|
||||||
console.log('[AVATAR HANDLER] Authorized:', req.session.user);
|
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);
|
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];
|
req.session = user[0];
|
||||||
|
|
||||||
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel
|
// 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);
|
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];
|
req.session = user[0];
|
||||||
console.log('[BANNER HANDLER] Authorized:', req.session.user);
|
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);
|
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];
|
req.session = user[0];
|
||||||
|
|
||||||
// CSRF validation
|
// 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 cfg from "./config.mjs";
|
||||||
import { createI18n } from "./i18n.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.tag) link.push("tag", encodeURIComponent(env.tag));
|
||||||
if (env.hall) link.push("h", encodeURIComponent(env.hall));
|
if (env.hall) link.push("h", encodeURIComponent(env.hall));
|
||||||
if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
|
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('/');
|
let tmp = link.length === 0 ? '/' : link.join('/');
|
||||||
if (!tmp.endsWith('/'))
|
if (!tmp.endsWith('/'))
|
||||||
@@ -375,13 +377,63 @@ export default new class {
|
|||||||
body: "401 - Unauthorized"
|
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') {
|
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 res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||||
}
|
}
|
||||||
return next();
|
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) {
|
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) {
|
if (!req.session) {
|
||||||
return res.reply({
|
return res.reply({
|
||||||
code: 401,
|
code: 401,
|
||||||
|
|||||||
@@ -548,6 +548,9 @@
|
|||||||
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
||||||
"no_uploads": "Keine Uploads gefunden",
|
"no_uploads": "Keine Uploads gefunden",
|
||||||
"no_favs": "Keine Favoriten",
|
"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",
|
"private_favorites": "private Favoriten",
|
||||||
"back_to_profile": "Zurück zum Profil",
|
"back_to_profile": "Zurück zum Profil",
|
||||||
"ban_modal_title": "Benutzer sperren",
|
"ban_modal_title": "Benutzer sperren",
|
||||||
|
|||||||
@@ -553,6 +553,9 @@
|
|||||||
"subscribe_uploads_btn": "Subscribe user to uploads",
|
"subscribe_uploads_btn": "Subscribe user to uploads",
|
||||||
"no_uploads": "no uploads found",
|
"no_uploads": "no uploads found",
|
||||||
"no_favs": "no favorites",
|
"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",
|
"private_favorites": "private favorites",
|
||||||
"back_to_profile": "Back to Profile",
|
"back_to_profile": "Back to Profile",
|
||||||
"ban_modal_title": "Ban User",
|
"ban_modal_title": "Ban User",
|
||||||
|
|||||||
@@ -546,6 +546,9 @@
|
|||||||
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
||||||
"no_uploads": "geen uploads gevonden",
|
"no_uploads": "geen uploads gevonden",
|
||||||
"no_favs": "geen favorieten",
|
"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",
|
"private_favorites": "privé favorieten",
|
||||||
"back_to_profile": "Terug naar Profiel",
|
"back_to_profile": "Terug naar Profiel",
|
||||||
"ban_modal_title": "Gebruiker Bannen",
|
"ban_modal_title": "Gebruiker Bannen",
|
||||||
|
|||||||
@@ -547,6 +547,9 @@
|
|||||||
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
||||||
"no_uploads": "keine Aufladierungen gefunden",
|
"no_uploads": "keine Aufladierungen gefunden",
|
||||||
"no_favs": "keine Favoriten",
|
"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",
|
"private_favorites": "private Favoriten",
|
||||||
"back_to_profile": "Zurück zum Profil",
|
"back_to_profile": "Zurück zum Profil",
|
||||||
"ban_modal_title": "Benutzer sperren",
|
"ban_modal_title": "Benutzer sperren",
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ const f0cklib = {
|
|||||||
${visibilityFilter}
|
${visibilityFilter}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${titleFilter}
|
${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``}
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${hallFilter}
|
${hallFilter}
|
||||||
@@ -513,7 +513,7 @@ const f0cklib = {
|
|||||||
${visibilityFilter}
|
${visibilityFilter}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${titleFilter}
|
${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``}
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${hallFilter}
|
${hallFilter}
|
||||||
@@ -531,7 +531,7 @@ const f0cklib = {
|
|||||||
const totalBefore = Number(countRows[0]?.total_before || 0);
|
const totalBefore = Number(countRows[0]?.total_before || 0);
|
||||||
return Math.floor(totalBefore / eps) + 1;
|
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) {
|
if (fav && rawUser) {
|
||||||
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||||
if (isPrivate && !isAllowed) {
|
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({
|
const filters = await buildFeedFilters({
|
||||||
rawUser,
|
rawUser,
|
||||||
rawTag,
|
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 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 });
|
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) {
|
if (total === null) {
|
||||||
const totalRows = await db`
|
const totalRows = await db`
|
||||||
select count(distinct items.id) as total
|
select count(distinct items.id) as total
|
||||||
from items
|
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
|
where
|
||||||
${db.unsafe(modequery)}
|
${db.unsafe(modequery)}
|
||||||
and items.active = true
|
and items.active = true
|
||||||
${visibilityFilter}
|
${visibilityFilter}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${titleFilter}
|
${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``}
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${hallFilter}
|
${hallFilter}
|
||||||
@@ -617,7 +632,7 @@ const f0cklib = {
|
|||||||
${xdFilter}
|
${xdFilter}
|
||||||
`;
|
`;
|
||||||
total = Number(totalRows[0].total);
|
total = Number(totalRows[0].total);
|
||||||
if (total > 0) setCachedCount(cacheKey, total);
|
if (total > 0 && !cleanIds) setCachedCount(cacheKey, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!total || total === 0) {
|
if (!total || total === 0) {
|
||||||
@@ -639,7 +654,7 @@ const f0cklib = {
|
|||||||
const pageIdRows = await db`
|
const pageIdRows = await db`
|
||||||
select items.id, items.is_pinned
|
select items.id, items.is_pinned
|
||||||
from items
|
from items
|
||||||
${fav ? db`
|
${fav && user ? db`
|
||||||
inner join favorites on favorites.item_id = items.id
|
inner join favorites on favorites.item_id = items.id
|
||||||
inner join "user" fav_u on fav_u.id = favorites.user_id
|
inner join "user" fav_u on fav_u.id = favorites.user_id
|
||||||
` : db``}
|
` : db``}
|
||||||
@@ -649,7 +664,8 @@ const f0cklib = {
|
|||||||
${visibilityFilter}
|
${visibilityFilter}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
${titleFilter}
|
${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``}
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${hallFilter}
|
${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``}
|
${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``}
|
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||||
${xdFilter}
|
${xdFilter}
|
||||||
${fav ? db`group by items.id, items.is_pinned` : db``}
|
${fav && user ? db`group by items.id, items.is_pinned` : db``}
|
||||||
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
|
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}
|
offset ${newerThan ? 0 : offset}
|
||||||
limit ${eps}
|
limit ${eps}
|
||||||
`;
|
`;
|
||||||
@@ -774,7 +796,7 @@ const f0cklib = {
|
|||||||
view_mode: fav ? 'favs' : 'uploads'
|
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) {
|
if (fav && rawUser) {
|
||||||
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
|
||||||
if (isPrivate && !isAllowed) {
|
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;
|
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||||
|
|
||||||
// --- title: prefix — search items.title instead of the tags table ---
|
// --- title: prefix — search items.title instead of the tags table ---
|
||||||
@@ -899,8 +926,9 @@ const f0cklib = {
|
|||||||
${titleFilter}
|
${titleFilter}
|
||||||
${hallFilter}
|
${hallFilter}
|
||||||
${userHallFilter}
|
${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``}
|
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||||
|
${idsFilter}
|
||||||
${mimeSQL}
|
${mimeSQL}
|
||||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
${!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``}
|
${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)
|
// Determine the effective mode for optimization check (similar to Random)
|
||||||
const nsfl_id = cfg.nsfl_tag_id || 3;
|
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) => {
|
const baseQuery = (whereClause, orderBy, limit = 1) => {
|
||||||
return db`
|
return db`
|
||||||
@@ -1038,7 +1066,7 @@ const f0cklib = {
|
|||||||
from items
|
from items
|
||||||
left join tags_assign on tags_assign.item_id = items.id
|
left join tags_assign on tags_assign.item_id = items.id
|
||||||
left join tags on tags.id = tags_assign.tag_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`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`
|
: 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,
|
COALESCE(c.is_pinned, false) as is_pinned,
|
||||||
c.video_time,
|
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,
|
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
|
FROM comments c
|
||||||
JOIN "user" u ON c.user_id = u.id
|
JOIN "user" u ON c.user_id = u.id
|
||||||
LEFT JOIN user_options uo ON uo.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
|
WHERE c.item_id = ${numericId} AND c.is_deleted = false
|
||||||
ORDER BY COALESCE(c.is_pinned, false) DESC,
|
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 ASC,
|
||||||
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
|
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
|
// Fetch comment file attachments
|
||||||
if (comments.length > 0) {
|
if (comments.length > 0) {
|
||||||
const commentIds = comments.map(c => c.id);
|
const commentIds = comments.map(c => c.id);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export default (router, tpl) => {
|
|||||||
contextUrl = query.fav === 'true'
|
contextUrl = query.fav === 'true'
|
||||||
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
|
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
|
||||||
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
|
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
|
||||||
|
} else if (query.fav === 'true') {
|
||||||
|
contextUrl = `/favs/${req.params.itemid}`;
|
||||||
}
|
}
|
||||||
if (query.mime) {
|
if (query.mime) {
|
||||||
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
|
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,
|
userHallOwner: query.userHallOwner || null,
|
||||||
mime: query.mime || (req.cookies.mime || null),
|
mime: query.mime || (req.cookies.mime || null),
|
||||||
fav: query.fav === 'true',
|
fav: query.fav === 'true',
|
||||||
|
ids: query.ids || null,
|
||||||
random: isRandom,
|
random: isRandom,
|
||||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||||
explicitStrict: query.strict === '1' || query.strict === 'true',
|
explicitStrict: query.strict === '1' || query.strict === 'true',
|
||||||
@@ -156,8 +159,9 @@ export default (router, tpl) => {
|
|||||||
data.uploader.color = null;
|
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.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.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.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(',') : '';
|
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,
|
is_admin: req.session?.admin,
|
||||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||||
fav: query.fav === 'true',
|
fav: query.fav === 'true',
|
||||||
|
ids: query.ids || null,
|
||||||
|
total: query.total ? parseInt(query.total, 10) : undefined,
|
||||||
random: isRandom,
|
random: isRandom,
|
||||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||||
explicitStrict: query.strict === '1' || query.strict === 'true',
|
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_score: item.xd_score,
|
||||||
xd_tier: item.xd_tier,
|
xd_tier: item.xd_tier,
|
||||||
personalized: !!item.personalized,
|
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)),
|
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
|
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
|
// PATCH /api/v2/items/:id/title — set or clear the title for an item
|
||||||
// Allowed by: item owner, moderators, admins
|
// 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;
|
const id = +req.params.id;
|
||||||
if (!id) return res.json({ success: false, msg: 'Invalid item id' }, 400);
|
if (!id) return res.json({ success: false, msg: 'Invalid item id' }, 400);
|
||||||
|
|
||||||
@@ -1413,7 +1414,52 @@ export default router => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group.post(/\/toggle-oc$/, lib.loggedin, async (req, res) => {
|
group.post(/\/favorites\/import$/, lib.loggedin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawIds = req.post?.ids ?? req.body?.ids;
|
||||||
|
let ids = [];
|
||||||
|
if (Array.isArray(rawIds)) {
|
||||||
|
ids = rawIds.map(Number);
|
||||||
|
} else if (typeof rawIds === 'string') {
|
||||||
|
ids = rawIds.split(',').map(Number);
|
||||||
|
}
|
||||||
|
ids = ids.filter(n => Number.isInteger(n) && n > 0);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return res.json({ success: true, imported: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit import batch to 500 items max for safety
|
||||||
|
const sliceIds = ids.slice(0, 500);
|
||||||
|
|
||||||
|
// Fetch valid existing items
|
||||||
|
const validItems = await db`
|
||||||
|
SELECT id FROM items WHERE id = ANY(${sliceIds}::int[]) AND active = true AND is_deleted = false
|
||||||
|
`;
|
||||||
|
const validIds = validItems.map(i => i.id);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const itemId of validIds) {
|
||||||
|
const inserted = await db`
|
||||||
|
INSERT INTO favorites (user_id, item_id)
|
||||||
|
VALUES (${req.session.id}, ${itemId})
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING item_id
|
||||||
|
`;
|
||||||
|
if (inserted.length > 0) {
|
||||||
|
count++;
|
||||||
|
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: itemId, scoreDelta: 5.0 }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, imported: count });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[FAVORITES_IMPORT_ERROR]', err);
|
||||||
|
return res.status(500).json({ success: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group.post(/\/toggle-oc$/, lib.registeredUser, async (req, res) => {
|
||||||
const postid = +req.post.postid;
|
const postid = +req.post.postid;
|
||||||
if (!postid) return res.json({ success: false, msg: 'No postid provided' }, 400);
|
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) {
|
if (cfg.enable_private_uploads === false && !req.session?.admin) {
|
||||||
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
|
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;
|
const itemid = +req.params.id;
|
||||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
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) {
|
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||||
return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
|
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;
|
const itemid = +req.params.id;
|
||||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
export default router => {
|
export default router => {
|
||||||
router.group(/^\/api\/v2\/settings/, group => {
|
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) {
|
if (!req.post.avatar) {
|
||||||
return res.json({
|
return res.json({
|
||||||
msg: 'no avatar provided',
|
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)
|
// 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
|
// Check if user has a custom avatar file
|
||||||
const userOpts = (await db`
|
const userOpts = (await db`
|
||||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
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)
|
// 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
|
// 6-char alphanumeric code
|
||||||
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
|
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)
|
// Get linked accounts (Discord & Matrix)
|
||||||
group.get(/\/link\/accounts/, lib.loggedin, async (req, res) => {
|
group.get(/\/link\/accounts/, lib.registeredUser, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const aliases = await db`
|
const aliases = await db`
|
||||||
SELECT alias, type FROM user_alias
|
SELECT alias, type FROM user_alias
|
||||||
@@ -199,7 +199,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Unlink account
|
// 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 {
|
try {
|
||||||
const alias = decodeURIComponent(req.params.alias);
|
const alias = decodeURIComponent(req.params.alias);
|
||||||
const type = req.params.type;
|
const type = req.params.type;
|
||||||
@@ -225,7 +225,7 @@ export default router => {
|
|||||||
|
|
||||||
// Backward compatibility routes for Discord (Deprecated)
|
// Backward compatibility routes for Discord (Deprecated)
|
||||||
// Discord Token Generation (Redirect to generic)
|
// 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
|
// Just call the logic inline
|
||||||
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
try {
|
try {
|
||||||
@@ -238,13 +238,13 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get linked Discord accounts (Legacy)
|
// 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'`;
|
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);
|
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unlink Discord account (Legacy)
|
// 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);
|
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'`;
|
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);
|
return res.json({ success: true, msg: 'Account unlinked' }, 200);
|
||||||
@@ -365,7 +365,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update Default Upload Visibility preference
|
// 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) {
|
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);
|
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
|
// 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;
|
const { color } = req.post;
|
||||||
|
|
||||||
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
|
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
|
||||||
@@ -420,7 +420,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update password
|
// 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;
|
const { current_password, new_password, new_password_confirm } = req.post;
|
||||||
|
|
||||||
if (!new_password || !new_password_confirm) {
|
if (!new_password || !new_password_confirm) {
|
||||||
@@ -461,7 +461,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update email
|
// Update email
|
||||||
group.put(/\/email/, lib.loggedin, async (req, res) => {
|
group.put(/\/email/, lib.registeredUser, async (req, res) => {
|
||||||
const { email } = req.post;
|
const { email } = req.post;
|
||||||
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
|
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
|
||||||
const cleanEmail = email.trim();
|
const cleanEmail = email.trim();
|
||||||
@@ -484,7 +484,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update Display Name
|
// 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;
|
const { display_name } = req.post;
|
||||||
|
|
||||||
if (display_name !== undefined && typeof display_name !== 'string') {
|
if (display_name !== undefined && typeof display_name !== 'string') {
|
||||||
@@ -517,7 +517,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update Description
|
// Update Description
|
||||||
group.put(/\/description/, lib.loggedin, async (req, res) => {
|
group.put(/\/description/, lib.registeredUser, async (req, res) => {
|
||||||
if (!cfg.websrv.enable_profile_description) {
|
if (!cfg.websrv.enable_profile_description) {
|
||||||
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
|
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
|
||||||
}
|
}
|
||||||
@@ -553,7 +553,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update Font preference
|
// Update Font preference
|
||||||
group.put(/\/font/, lib.loggedin, async (req, res) => {
|
group.put(/\/font/, lib.registeredUser, async (req, res) => {
|
||||||
const { font } = req.post;
|
const { font } = req.post;
|
||||||
|
|
||||||
// F-023 Security: Validate font against actual files on disk
|
// F-023 Security: Validate font against actual files on disk
|
||||||
@@ -846,7 +846,7 @@ export default router => {
|
|||||||
|
|
||||||
// GET /api/v2/settings/api-key
|
// 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).
|
// 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) {
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
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
|
// POST /api/v2/settings/api-key/regenerate
|
||||||
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
|
// 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) {
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
||||||
}
|
}
|
||||||
@@ -904,7 +904,7 @@ export default router => {
|
|||||||
|
|
||||||
// DELETE /api/v2/settings/api-key
|
// DELETE /api/v2/settings/api-key
|
||||||
// Revokes (deletes) the user's 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) {
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
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
|
// GET /api/v2/settings/api-key/sharex-config
|
||||||
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
|
// 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) {
|
if (cfg.websrv.enable_user_api_keys === false) {
|
||||||
return res.status(403).reply({ body: 'API keys are disabled' });
|
return res.status(403).reply({ body: 'API keys are disabled' });
|
||||||
}
|
}
|
||||||
@@ -1005,7 +1005,7 @@ export default router => {
|
|||||||
|
|
||||||
// GET /api/v2/settings/invites
|
// GET /api/v2/settings/invites
|
||||||
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
|
// 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) {
|
if (cfg.websrv.enable_user_invites === false) {
|
||||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||||
}
|
}
|
||||||
@@ -1085,7 +1085,7 @@ export default router => {
|
|||||||
|
|
||||||
// POST /api/v2/settings/invites/create
|
// POST /api/v2/settings/invites/create
|
||||||
// Generates a new invite token if eligible and slots remain.
|
// 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) {
|
if (cfg.websrv.enable_user_invites === false) {
|
||||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||||
}
|
}
|
||||||
@@ -1151,7 +1151,7 @@ export default router => {
|
|||||||
|
|
||||||
// POST /api/v2/settings/invites/delete
|
// POST /api/v2/settings/invites/delete
|
||||||
// Deletes an unused invite token owned by the calling user.
|
// 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) {
|
if (cfg.websrv.enable_user_invites === false) {
|
||||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export default router => {
|
|||||||
router.group(/^\/api\/v2/, group => {
|
router.group(/^\/api\/v2/, group => {
|
||||||
|
|
||||||
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
|
// ── 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 jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
|
||||||
const state = progressMap.get(jobId);
|
const state = progressMap.get(jobId);
|
||||||
res.setHeader?.('Cache-Control', 'no-store');
|
res.setHeader?.('Cache-Control', 'no-store');
|
||||||
@@ -307,7 +307,7 @@ export default router => {
|
|||||||
return [...new Set(tags)];
|
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;
|
const url = req.url.qs?.url;
|
||||||
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
|
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 {
|
try {
|
||||||
if (!cfg.websrv.web_url_upload) {
|
if (!cfg.websrv.web_url_upload) {
|
||||||
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
|
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 { promises as fs } from "fs";
|
||||||
import { applyWordFilter } from "../wordfilter.mjs";
|
import { applyWordFilter } from "../wordfilter.mjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } from "../anon_auth.mjs";
|
||||||
|
import { getEnableAnonymousAccess } from "../settings.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
|
|
||||||
@@ -389,6 +391,26 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
// Post a comment
|
// Post a comment
|
||||||
router.post('/api/comments', async (req, res) => {
|
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" }) });
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
|
||||||
|
|
||||||
// Rate limit regular users (admins and mods are exempt)
|
// 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,
|
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
username_color: req.session.username_color,
|
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,
|
xd_score: xdRow?.xd_score ?? null,
|
||||||
video_time: newComment[0]?.video_time ?? 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
|
// 1. Thread live update
|
||||||
@@ -656,11 +681,14 @@ export default (router, tpl) => {
|
|||||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || 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,
|
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,
|
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
|
// Automatically subscribe user to the thread
|
||||||
|
|||||||
@@ -233,6 +233,12 @@ export default (router, tpl) => {
|
|||||||
return res.redirect('/login');
|
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
|
// Auto-persist strict mode from URL to session if it's there
|
||||||
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
|
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');
|
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?
|
// Is the current user a moderator/admin?
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||||
// Can the current user manage this item (owner, admin, or mod)?
|
// 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?
|
// Is the item's MIME type suitable for metadata extraction?
|
||||||
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
|
// 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])));
|
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 });
|
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
|
// Specific route for direct item links: /user/:user/:itemid
|
||||||
// This avoids ambiguity with the profile route
|
// This avoids ambiguity with the profile route
|
||||||
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute);
|
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,
|
joined: user?.created_at || null,
|
||||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
enable_swf: cfg.enable_swf,
|
enable_swf: cfg.enable_swf,
|
||||||
enable_data_export: cfg.websrv.enable_data_export,
|
enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export,
|
||||||
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
|
enable_user_api_keys: !req.session?.is_anon && cfg.websrv.enable_user_api_keys !== false,
|
||||||
enable_user_invites: cfg.websrv.enable_user_invites !== false,
|
enable_user_invites: !req.session?.is_anon && cfg.websrv.enable_user_invites !== false,
|
||||||
site_domain: cfg.main.url.domain,
|
site_domain: cfg.main.url.domain,
|
||||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||||
page_meta: {
|
page_meta: {
|
||||||
@@ -74,6 +74,10 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
group.get('/export-data', auth, async (req, res) => {
|
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) {
|
if (!cfg.websrv.enable_data_export) {
|
||||||
res.status(403).reply({ body: 'Export disabled' });
|
res.status(403).reply({ body: 'Export disabled' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -169,9 +169,9 @@ export default (router, tpl) => {
|
|||||||
// Precompute boolean helpers for template @if() — must match index.mjs pattern
|
// Precompute boolean helpers for template @if() — must match index.mjs pattern
|
||||||
if (data.item) {
|
if (data.item) {
|
||||||
const session = data.session;
|
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.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.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.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(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export const getEnableItemSlugs = () => {
|
|||||||
return true;
|
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 () => {
|
export const ensureAllItemsHaveSlugs = async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
|
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 { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||||
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
||||||
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_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 { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||||
import { createI18n } from "./inc/i18n.mjs";
|
import { createI18n } from "./inc/i18n.mjs";
|
||||||
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
||||||
|
|
||||||
import security from "./inc/security.mjs";
|
import security from "./inc/security.mjs";
|
||||||
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
|
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
|
||||||
|
import { verifySignature } from "./inc/anon_auth.mjs";
|
||||||
|
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
const _require = createRequire(import.meta.url);
|
const _require = createRequire(import.meta.url);
|
||||||
@@ -830,10 +831,11 @@ process.on('uncaughtException', err => {
|
|||||||
user = [_cachedRow];
|
user = [_cachedRow];
|
||||||
} else {
|
} else {
|
||||||
user = await db`
|
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"
|
from "user_sessions"
|
||||||
left join "user" on "user".id = "user_sessions".user_id
|
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 "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}
|
where "user_sessions".session = ${_sessionHash}
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
@@ -851,6 +853,18 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.session = user[0];
|
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
|
// csrf_token is loaded from user_sessions table via the session query above
|
||||||
|
|
||||||
// Ban check
|
// Ban check
|
||||||
@@ -1108,6 +1122,20 @@ process.on('uncaughtException', err => {
|
|||||||
// CSRF validation helper — used by route handlers and global middleware
|
// CSRF validation helper — used by route handlers and global middleware
|
||||||
const validateCsrf = async (req, res) => {
|
const validateCsrf = async (req, res) => {
|
||||||
if (req.session && req.session.csrf_token) {
|
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;
|
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
|
// 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.
|
// because the session middleware will have completed by the time router callbacks execute.
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
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
|
// DM attachment upload validates CSRF internally
|
||||||
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
|
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
|
||||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
// 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,
|
enable_private_uploads: cfg.enable_private_uploads !== false,
|
||||||
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
||||||
get enable_item_slugs() { return getEnableItemSlugs(); },
|
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)),
|
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,
|
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,
|
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);
|
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.
|
// CSRF validation — required for browser sessions, skipped for API key auth.
|
||||||
if (!req.session.api_key_auth) {
|
if (!req.session.api_key_auth) {
|
||||||
const csrfToken = req.headers['x-csrf-token'];
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
|||||||
@@ -158,14 +158,14 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="gapRight">
|
<div class="gapRight">
|
||||||
@if(session)
|
@if(!user_alternative_infobox && session)
|
||||||
@if(!user_alternative_infobox)
|
|
||||||
@if(user_has_favorited)
|
@if(user_has_favorited)
|
||||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@else
|
@else
|
||||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@endif
|
@endif
|
||||||
@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 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 {{ 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>
|
<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) data-is-locked="true" @endif>
|
||||||
@if(item.is_comments_locked && !is_mod_or_admin)
|
@if(item.is_comments_locked && !is_mod_or_admin)
|
||||||
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
|
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
|
||||||
@elseif(session)
|
@else
|
||||||
<div class="comment-input main-input">
|
<div class="comment-input main-input">
|
||||||
<textarea disabled></textarea>
|
<textarea disabled></textarea>
|
||||||
</div>
|
</div>
|
||||||
@else
|
|
||||||
<div class="login-placeholder"><a href="/login" class="login-trigger-btn">Login</a> to comment</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -21,12 +21,10 @@
|
|||||||
@if(item.is_comments_locked) data-is-locked="true" @endif>
|
@if(item.is_comments_locked) data-is-locked="true" @endif>
|
||||||
@if(item.is_comments_locked && !is_mod_or_admin)
|
@if(item.is_comments_locked && !is_mod_or_admin)
|
||||||
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
|
<div class="lock-notice">🔒 Comments are disabled on this thread.</div>
|
||||||
@elseif(session)
|
@else
|
||||||
<div class="comment-input main-input">
|
<div class="comment-input main-input">
|
||||||
<textarea disabled></textarea>
|
<textarea disabled></textarea>
|
||||||
</div>
|
</div>
|
||||||
@else
|
|
||||||
<div class="login-placeholder"><a href="/login" class="login-trigger-btn">Login</a> to comment</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -158,6 +156,8 @@
|
|||||||
@else
|
@else
|
||||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||||
@endif
|
@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 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 {{ 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>
|
<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 -->
|
<!-- Quick navigation -->
|
||||||
<nav id="settings-quicknav" aria-label="Settings sections">
|
<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>
|
<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>
|
<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>
|
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
|
||||||
@endif
|
@endif
|
||||||
|
@if(!session.is_anon)
|
||||||
<a href="#account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</a>
|
<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>
|
<a href="#linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</a>
|
||||||
@endif
|
@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>
|
<a href="#apikey"><i class="fa-solid fa-key"></i> API Key</a>
|
||||||
@endif
|
@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>
|
<a href="#invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</a>
|
||||||
@endif
|
@endif
|
||||||
</nav>
|
</nav>
|
||||||
@@ -74,6 +81,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
@if(!session.is_anon)
|
||||||
<!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ -->
|
||||||
<h2 id="profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</h2>
|
<h2 id="profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</h2>
|
||||||
|
|
||||||
@@ -187,6 +195,7 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -510,6 +519,55 @@
|
|||||||
|
|
||||||
</div>
|
</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)
|
@if(enable_data_export)
|
||||||
<!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ -->
|
||||||
<h2 id="export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
<h2 id="export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
||||||
@@ -553,6 +611,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if(!session.is_anon)
|
||||||
<!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ -->
|
||||||
<h2 id="account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</h2>
|
<h2 id="account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</h2>
|
||||||
<div class="account-settings-wrapper"
|
<div class="account-settings-wrapper"
|
||||||
@@ -612,8 +671,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if(matrix_enabled || telegram_enabled)
|
@if(!session.is_anon && (matrix_enabled || telegram_enabled))
|
||||||
<!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ -->
|
||||||
<h2 id="linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</h2>
|
<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;">
|
<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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(enable_user_api_keys)
|
@if(!session.is_anon && enable_user_api_keys)
|
||||||
<!-- ═══════════════════════════════ API KEY ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ API KEY ═══════════════════════════════ -->
|
||||||
<h2 id="apikey"><i class="fa-solid fa-key"></i> Upload API Key</h2>
|
<h2 id="apikey"><i class="fa-solid fa-key"></i> Upload API Key</h2>
|
||||||
<div id="api-key-section" class="account-settings-wrapper"
|
<div id="api-key-section" class="account-settings-wrapper"
|
||||||
@@ -686,7 +746,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(enable_user_invites)
|
@if(!session.is_anon && enable_user_invites)
|
||||||
<!-- ═══════════════════════════════ INVITES ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ INVITES ═══════════════════════════════ -->
|
||||||
<h2 id="invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</h2>
|
<h2 id="invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</h2>
|
||||||
<div id="invite-section" class="account-settings-wrapper"
|
<div id="invite-section" class="account-settings-wrapper"
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@if(private_society && !session)
|
@if(private_society && !session)
|
||||||
<script>
|
<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.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
|
||||||
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
|
window.f0ckDefaultThumbSize = "{{ default_thumb_size }}";
|
||||||
(() => {
|
(() => {
|
||||||
@@ -421,6 +421,7 @@
|
|||||||
public_untagged: @if(public_untagged) true @else false @endif,
|
public_untagged: @if(public_untagged) true @else false @endif,
|
||||||
public_nsfw: @if(public_nsfw) 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_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,
|
strict_mode: @if(session && session.strict_mode) true @else false @endif,
|
||||||
logged_in: @if(session) 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,
|
hide_comments_from_public: @if(hide_comments_from_public) true @else false @endif,
|
||||||
@@ -436,6 +437,9 @@
|
|||||||
nsfl_tag_id: {{ nsfl_tag_id }},
|
nsfl_tag_id: {{ nsfl_tag_id }},
|
||||||
enable_nsfl: @if(enable_nsfl) true @else false @endif,
|
enable_nsfl: @if(enable_nsfl) true @else false @endif,
|
||||||
user: @if(session) "{{ session.user }}" @else null @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,
|
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,
|
username_color: @if(session && session.username_color) "{!! session.username_color !!}" @else null @endif,
|
||||||
id: @if(session && session.id) {{ session.id }} @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_extracting: "{{ t('upload.url_tracker_extracting') || 'Extracting' }}",
|
||||||
url_tracker_complete: "{{ t('upload.url_tracker_complete') || 'Complete!' }}",
|
url_tracker_complete: "{{ t('upload.url_tracker_complete') || 'Complete!' }}",
|
||||||
url_tracker_failed: "{{ t('upload.url_tracker_failed') || 'Upload failed' }}",
|
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>
|
||||||
|
<script src="/s/js/anon_ssh.js?v={{ ts }}"></script>
|
||||||
<script src="/s/js/f0ckm.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/sidebar-activity.js?v={{ ts }}"></script>
|
||||||
<script src="/s/js/flash_yank.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="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
|
<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>
|
<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>
|
<style>
|
||||||
html { background-color: #000; color: #fff; }
|
html { background-color: #000; color: #fff; }
|
||||||
|
|||||||
+212
-7
@@ -14,7 +14,9 @@
|
|||||||
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
|
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
|
||||||
<div class="nav-collapse" id="navbarContent">
|
<div class="nav-collapse" id="navbarContent">
|
||||||
<div class="nav-links">
|
<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>
|
<a id="nav-upload-link" style="cursor:pointer;"><i class="fa-solid fa-angle-up"></i> {{ t('nav.upload') }}</a>
|
||||||
|
@endif
|
||||||
@if(meme_creator)
|
@if(meme_creator)
|
||||||
<a id="nav-meme-link" href="/meme"><i class="fa-regular fa-image"></i> {{ t('nav.meme') }}</a>
|
<a id="nav-meme-link" href="/meme"><i class="fa-regular fa-image"></i> {{ t('nav.meme') }}</a>
|
||||||
@endif
|
@endif
|
||||||
@@ -42,7 +44,9 @@
|
|||||||
<!-- Avatar dropdown -->
|
<!-- Avatar dropdown -->
|
||||||
<div class="nav-user-dropdown">
|
<div class="nav-user-dropdown">
|
||||||
<button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle">
|
<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 }}">
|
<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)
|
@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 }}">
|
<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>
|
<span class="nav-avatar-caret">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="nav-user-menu" id="nav-user-menu">
|
<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>
|
<a href="/user/{!! session.user.toLowerCase() !!}">{{ t('nav.profile') }}</a>
|
||||||
@if(userhalls_enabled)
|
@if(userhalls_enabled)
|
||||||
<a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a>
|
<a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a>
|
||||||
@endif
|
@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>
|
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
|
||||||
<div class="nav-user-divider"></div>
|
<div class="nav-user-divider"></div>
|
||||||
<a href="/logout" class="mobile-only">{{ t('nav.logout') }}</a>
|
<a href="/logout" class="mobile-only">{{ t('nav.logout') }}</a>
|
||||||
@@ -91,10 +102,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Favs -->
|
<!-- 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 -->
|
<!-- DM -->
|
||||||
@if(private_messages)
|
@if(private_messages && !session.is_anon)
|
||||||
<div id="nav-dm" class="nav-item-rel">
|
<div id="nav-dm" class="nav-item-rel">
|
||||||
<a href="/messages" id="nav-dm-btn" title="Direct Messages">
|
<a href="/messages" id="nav-dm-btn" title="Direct Messages">
|
||||||
<i class="fa-solid fa-envelope"></i>
|
<i class="fa-solid fa-envelope"></i>
|
||||||
@@ -261,13 +272,32 @@
|
|||||||
|
|
||||||
<div class="nav-right-group">
|
<div class="nav-right-group">
|
||||||
<div class="nav-user-dropdown">
|
<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>
|
<button class="nav-user-btn" id="nav-visitor-toggle">
|
||||||
<div class="nav-user-menu" id="nav-visitor-menu">
|
<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-login-btn">{{ t('nav.login') }}</a>
|
||||||
<a href="#" id="nav-register-btn">{{ t('nav.register') }}</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>
|
||||||
</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"
|
<button class="navbar-toggler" type="button" id="nav-toggler"
|
||||||
onclick="document.getElementById('navbarContent').classList.toggle('show'); this.classList.toggle('is-open')">
|
onclick="document.getElementById('navbarContent').classList.toggle('show'); this.classList.toggle('is-open')">
|
||||||
<span></span><span></span><span></span>
|
<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>
|
{{ t('auth.no_account') }} <a href="#" id="login-to-register" style="color: var(--accent); text-decoration: underline;">{{ t('auth.register_now') }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -346,6 +383,174 @@
|
|||||||
</div>
|
</div>
|
||||||
</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)
|
@if(!private_society || session)
|
||||||
<!-- Shortcuts Modal -->
|
<!-- Shortcuts Modal -->
|
||||||
<div id="shortcuts-modal" style="display: none;">
|
<div id="shortcuts-modal" style="display: none;">
|
||||||
|
|||||||
Reference in New Issue
Block a user