From 90860b9279c71ef7422d4fd3ef155dd907e80f3c Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sat, 12 Sep 2026 21:55:40 +0200 Subject: [PATCH] gfsd --- migrations/add_anon_activity_and_bans.sql | 48 ++ migrations/add_hardware_fingerprint_bans.sql | 26 + public/s/js/anon_ssh.js | 140 +++- public/s/js/sidebar-activity.js | 50 +- src/inc/anon_auth.mjs | 80 +- src/inc/locales/de.json | 1 + src/inc/locales/en.json | 1 + src/inc/locales/nl.json | 1 + src/inc/locales/zange.json | 1 + src/inc/routeinc/f0cklib.mjs | 104 ++- src/inc/routes/admin.mjs | 209 +++++ src/inc/routes/apiv2/anon.mjs | 133 +++- src/inc/routes/apiv2/index.mjs | 22 +- src/inc/routes/apiv2/tags.mjs | 22 + src/inc/routes/banned.mjs | 35 +- src/inc/routes/comments.mjs | 30 +- src/inc/routes/ranking.mjs | 15 + src/inc/security.mjs | 205 +++++ src/index.mjs | 45 +- src/upload_handler.mjs | 29 +- views/admin.html | 1 + views/admin/bans.html | 788 +++++++++++++++++++ views/ranking.html | 1 + views/user-partial.html | 16 +- 24 files changed, 1958 insertions(+), 45 deletions(-) create mode 100644 migrations/add_anon_activity_and_bans.sql create mode 100644 migrations/add_hardware_fingerprint_bans.sql create mode 100644 views/admin/bans.html diff --git a/migrations/add_anon_activity_and_bans.sql b/migrations/add_anon_activity_and_bans.sql new file mode 100644 index 0000000..451f85a --- /dev/null +++ b/migrations/add_anon_activity_and_bans.sql @@ -0,0 +1,48 @@ +-- Migration: Add anonymous activity log and multi-layer anonymous banning tables + +CREATE TABLE IF NOT EXISTS anon_activity_log ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL, + fingerprint VARCHAR(128), + action VARCHAR(64) NOT NULL, + target_id INT, + ip VARCHAR(128) NOT NULL, + user_agent TEXT, + details JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_anon_act_user_id ON anon_activity_log (user_id); +CREATE INDEX IF NOT EXISTS idx_anon_act_action ON anon_activity_log (action); +CREATE INDEX IF NOT EXISTS idx_anon_act_ip ON anon_activity_log (ip); +CREATE INDEX IF NOT EXISTS idx_anon_act_created ON anon_activity_log (created_at); + +CREATE TABLE IF NOT EXISTS banned_ips ( + id SERIAL PRIMARY KEY, + ip VARCHAR(128) NOT NULL UNIQUE, + ip_hash VARCHAR(128), + reason TEXT DEFAULT 'Banned by administrator', + banned_by INT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_banned_ips_ip ON banned_ips (ip); +CREATE INDEX IF NOT EXISTS idx_banned_ips_hash ON banned_ips (ip_hash); + +CREATE TABLE IF NOT EXISTS banned_fingerprints ( + id SERIAL PRIMARY KEY, + fingerprint VARCHAR(128) NOT NULL UNIQUE, + pubkey TEXT, + reason TEXT DEFAULT 'Banned by administrator', + banned_by INT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_banned_fp_fp ON banned_fingerprints (fingerprint); + +ALTER TABLE comments ADD COLUMN IF NOT EXISTS ip VARCHAR(128); +ALTER TABLE items ADD COLUMN IF NOT EXISTS uploader_ip VARCHAR(128); +ALTER TABLE anon_identities ADD COLUMN IF NOT EXISTS created_ip VARCHAR(128); +ALTER TABLE anon_identities ADD COLUMN IF NOT EXISTS last_ip VARCHAR(128); diff --git a/migrations/add_hardware_fingerprint_bans.sql b/migrations/add_hardware_fingerprint_bans.sql new file mode 100644 index 0000000..eb5aef7 --- /dev/null +++ b/migrations/add_hardware_fingerprint_bans.sql @@ -0,0 +1,26 @@ +-- Migration: Add Hardware Fingerprint Banning +-- Adds banned_hardware_fingerprints table and links hw_fingerprint to anon_identities and anon_activity_log. + +CREATE TABLE IF NOT EXISTS banned_hardware_fingerprints ( + id SERIAL PRIMARY KEY, + hw_fingerprint VARCHAR(128) UNIQUE NOT NULL, + banned_by INT REFERENCES "user"(id) ON DELETE SET NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_banned_hw_fp ON banned_hardware_fingerprints(hw_fingerprint); +CREATE INDEX IF NOT EXISTS idx_banned_hw_expires ON banned_hardware_fingerprints(expires_at); + +-- Add hardware fingerprint column to anon_identities +ALTER TABLE anon_identities +ADD COLUMN IF NOT EXISTS hw_fingerprint VARCHAR(128); + +CREATE INDEX IF NOT EXISTS idx_anon_identities_hw ON anon_identities(hw_fingerprint); + +-- Add hardware fingerprint column to anon_activity_log +ALTER TABLE anon_activity_log +ADD COLUMN IF NOT EXISTS hw_fingerprint VARCHAR(128); + +CREATE INDEX IF NOT EXISTS idx_anon_activity_hw ON anon_activity_log(hw_fingerprint); diff --git a/public/s/js/anon_ssh.js b/public/s/js/anon_ssh.js index f266171..67d81bc 100644 --- a/public/s/js/anon_ssh.js +++ b/public/s/js/anon_ssh.js @@ -83,6 +83,102 @@ this.rawPub = null; this.rawSeed = null; this.isSessionReady = false; + this.hwFingerprint = null; + } + + /** + * Compute a hardware-level fingerprint based on GPU, CPU, RAM, screen, and Web Audio DSP. + * Survives across private browsing windows and different browsers on the same physical device. + */ + async getHardwareFingerprint() { + if (this.hwFingerprint) return this.hwFingerprint; + + try { + let glVendor = ''; + let glRenderer = ''; + let glLimits = ''; + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + if (gl) { + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + if (ext) { + glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || ''; + glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || ''; + } + glLimits = [ + gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0, + gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0, + gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0, + gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0, + gl.getParameter(gl.MAX_VARYING_VECTORS) || 0, + gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0 + ].join(','); + } + } catch (e) {} + + const concurrency = navigator.hardwareConcurrency || 0; + const memory = navigator.deviceMemory || 0; + const screenInfo = [ + window.screen ? window.screen.width : 0, + window.screen ? window.screen.height : 0, + window.screen ? window.screen.colorDepth : 0, + window.screen ? window.screen.pixelDepth : 0, + window.devicePixelRatio || 1 + ].join('x'); + + let audioFp = ''; + try { + const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; + if (AudioContext) { + const actx = new AudioContext(1, 44100, 44100); + const osc = actx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(10000, actx.currentTime); + + const comp = actx.createDynamicsCompressor(); + comp.threshold.setValueAtTime(-50, actx.currentTime); + comp.knee.setValueAtTime(40, actx.currentTime); + comp.ratio.setValueAtTime(12, actx.currentTime); + comp.reduction.setValueAtTime(-20, actx.currentTime); + comp.attack.setValueAtTime(0, actx.currentTime); + comp.release.setValueAtTime(0.25, actx.currentTime); + + osc.connect(comp); + comp.connect(actx.destination); + osc.start(0); + + const renderedBuffer = await actx.startRendering(); + const channel = renderedBuffer.getChannelData(0); + let sum = 0; + const end = Math.min(channel.length, 5000); + for (let i = 4500; i < end; i++) { + sum += Math.abs(channel[i] || 0); + } + audioFp = sum.toFixed(7); + } + } catch (e) {} + + const rawHardware = [ + glVendor, + glRenderer, + glLimits, + concurrency, + memory, + screenInfo, + audioFp + ].join('~~~'); + + const encoder = new TextEncoder(); + const data = encoder.encode(rawHardware); + const hashBuf = await window.crypto.subtle.digest('SHA-256', data); + const hashHex = bytesToHex(new Uint8Array(hashBuf)); + this.hwFingerprint = `HW:${hashHex}`; + return this.hwFingerprint; + } catch (err) { + console.warn('[ANON_SSH] Failed to compute hardware fingerprint:', err); + return null; + } } getDomain() { @@ -430,6 +526,8 @@ const timestamp = Date.now(); const message = `anon-auth:${timestamp}:${this.pubkey}`; const signature = await this.sign(message); + const tombstone = this.getTombstone(); + const hwFingerprint = await this.getHardwareFingerprint(); const res = await fetch('/api/v2/anon/session', { method: 'POST', @@ -437,11 +535,27 @@ body: JSON.stringify({ pubkey: this.pubkey, timestamp: timestamp, - signature: signature + signature: signature, + tombstone: tombstone, + hw_fingerprint: hwFingerprint }) }); const data = await res.json(); + if (data.banned) { + this.setTombstone({ + banned: true, + fingerprint: data.fingerprint || this.fingerprint, + hw_fingerprint: data.hw_fingerprint || hwFingerprint, + reason: data.reason, + expires: data.expires + }); + if (window.location.pathname !== '/banned') { + window.location.href = data.redirect || '/banned'; + } + return; + } + if (data.success) { this.isSessionReady = true; if (data.csrf_token) { @@ -452,7 +566,8 @@ window.f0ckAnonIdentity = { userId: data.user_id, fingerprint: data.fingerprint, - shortFingerprint: data.short_fingerprint + shortFingerprint: data.short_fingerprint, + hwFingerprint: data.hw_fingerprint || hwFingerprint }; window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity })); } @@ -461,6 +576,27 @@ } } + /** + * Get client ban tombstone if previously banned. + */ + getTombstone() { + try { + const raw = localStorage.getItem('f0ck_anon_tombstone'); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } + } + + /** + * Save client ban tombstone. + */ + setTombstone(tombstone) { + try { + localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(tombstone)); + } catch (e) {} + } + /** * Trigger browser download of id_ed25519 private key file */ diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index c2ff5db..a360f8b 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -1289,6 +1289,33 @@ } }; + const enforceSidebarRecommendationDistribution = (items) => { + if (!Array.isArray(items) || items.length <= 3) return items; + // Collect indices of personalized recommendations in the first 3 items + const recIndicesInFirst3 = []; + for (let i = 0; i < Math.min(3, items.length); i++) { + if (items[i] && items[i].personalized) recIndicesInFirst3.push(i); + } + + // If more than 1 recommendation in first 3, swap extras with non-rec items from index >= 3 + if (recIndicesInFirst3.length > 1) { + let nonRecIdx = 3; + for (let r = 1; r < recIndicesInFirst3.length; r++) { + const swapSlot = recIndicesInFirst3[r]; + while (nonRecIdx < items.length && items[nonRecIdx] && items[nonRecIdx].personalized) { + nonRecIdx++; + } + if (nonRecIdx < items.length) { + const tmp = items[swapSlot]; + items[swapSlot] = items[nonRecIdx]; + items[nonRecIdx] = tmp; + nonRecIdx++; + } + } + } + return items; + }; + const loadRecommendations = async (silent = false) => { const container = document.getElementById('sidebar-recommendations-container'); if (!container || recommendationsLoading) return; @@ -1316,6 +1343,7 @@ const items = data.items || data.videos || []; if (data.success && items.length > 0) { + enforceSidebarRecommendationDistribution(items); currentRecommendations = items; recommendationsLoaded = true; @@ -1368,7 +1396,7 @@ const affParams = getSessionAffinityParams(); const mime = getCurrentMimeFilter(); const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : ''; - const res = await fetch(`/api/v2/recommendations?limit=15&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}${affParams}`, { + const res = await fetch(`/api/v2/recommendations?limit=15&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}&continuation=1${affParams}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await res.json(); @@ -1877,10 +1905,28 @@ } const excludeArr = Array.from(excludeSet).slice(-120); + const cards = Array.from(container ? container.querySelectorAll('.sidebar-video-card') : []); + const cardIndex = cards.indexOf(card); + let preferParam = ''; + if (cardIndex >= 0 && cardIndex < 3) { + // Keep exactly 1 recommendation in the first 3 cards + const isPersonalized = card.dataset.personalized === 'true'; + preferParam = `&prefer_personalized=${isPersonalized ? 'true' : 'false'}`; + } else if (cardIndex >= 3) { + // Ensure recommendations remain sporadic (never consecutive) + const prevCard = cards[cardIndex - 1]; + const nextCard = cards[cardIndex + 1]; + const prevIsRec = prevCard && prevCard.dataset.personalized === 'true'; + const nextIsRec = nextCard && nextCard.dataset.personalized === 'true'; + if (prevIsRec || nextIsRec) { + preferParam = '&prefer_personalized=false'; + } + } + const affParams = getSessionAffinityParams(); const mime = getCurrentMimeFilter(); const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : ''; - const res = await fetch(`/api/v2/recommendations?limit=1&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}${affParams}`, { + const res = await fetch(`/api/v2/recommendations?limit=1&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}${preferParam}${affParams}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await res.json(); diff --git a/src/inc/anon_auth.mjs b/src/inc/anon_auth.mjs index 95e38b4..0530555 100644 --- a/src/inc/anon_auth.mjs +++ b/src/inc/anon_auth.mjs @@ -86,14 +86,57 @@ export function verifySignature(sshPubkey, message, signature) { } } +import security from './security.mjs'; +import { getHashUserIps } from './settings.mjs'; + +/** + * Get IP for audit/logging, hashed if hash_user_ips is enabled in config. + * @param {object} req + * @returns {string} + */ +export function resolveAuditIP(req) { + if (!req) return 'unknown'; + const rawIp = security.getRealIP(req); + return getHashUserIps() ? security.hashIP(rawIp) : rawIp; +} + +/** + * Log activity for an anonymous user (or session). + * @param {object} req + * @param {{ action: string, targetId?: number|string, details?: object, hwFingerprint?: string }} params + */ +export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) { + try { + const rawIp = security.getRealIP(req); + const ip = getHashUserIps() ? security.hashIP(rawIp) : rawIp; + const userId = req?.session?.id || null; + if (!userId) return; + const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null; + const hwFp = hwFingerprint || req?.session?.hw_fingerprint || null; + const numTargetId = targetId ? parseInt(targetId, 10) : null; + + await db` + INSERT INTO anon_activity_log (user_id, fingerprint, hw_fingerprint, ip, action, target_id, details) + VALUES (${userId}, ${fingerprint}, ${hwFp}, ${ip}, ${action}, ${!isNaN(numTargetId) ? numTargetId : null}, ${details ? JSON.stringify(details) : null}) + `; + + await security.logUserIP(userId, rawIp); + } catch (err) { + console.error('[ANON_ACTIVITY_LOG] Failed to log activity:', err); + } +} + /** * Find or create a shadow user in the database for an anonymous SSH identity. * @param {string} pubkey * @param {string} fingerprint + * @param {object} [req] + * @param {string} [hwFingerprint] * @returns {Promise<{ userId: number, isNew: boolean }>} */ -export async function getOrCreateAnonUser(pubkey, fingerprint) { +export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFingerprint = null) { const normPubkey = pubkey.trim(); + const auditIp = req ? resolveAuditIP(req) : null; const existing = await db` SELECT user_id FROM anon_identities WHERE pubkey = ${normPubkey} @@ -101,7 +144,13 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) { `; if (existing.length > 0) { - await db`UPDATE anon_identities SET last_seen = NOW() WHERE pubkey = ${normPubkey}`; + await db` + UPDATE anon_identities + SET last_seen = NOW() + ${auditIp ? db`, last_ip = ${auditIp}` : db``} + ${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``} + WHERE pubkey = ${normPubkey} + `; return { userId: existing[0].user_id, isNew: false }; } @@ -131,9 +180,12 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) { `; await db` - INSERT INTO anon_identities (user_id, pubkey, fingerprint) - VALUES (${userId}, ${normPubkey}, ${fingerprint}) - ON CONFLICT (pubkey) DO NOTHING + INSERT INTO anon_identities (user_id, pubkey, fingerprint, created_ip, last_ip, hw_fingerprint) + VALUES (${userId}, ${normPubkey}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint}) + ON CONFLICT (pubkey) DO UPDATE + SET last_seen = NOW() + ${auditIp ? db`, last_ip = ${auditIp}` : db``} + ${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``} `; return { userId, isNew: true }; @@ -143,11 +195,24 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) { * Create a valid session in user_sessions for this anonymous user. * @param {number} userId * @param {object} req + * @param {string} [hwFingerprint] * @returns {Promise<{ session: string, csrf_token: string }>} */ -export async function createAnonSession(userId, req) { +export async function createAnonSession(userId, req, hwFingerprint = null) { + const auditIp = resolveAuditIP(req); + + // Update anon_identities last_ip, created_ip, and hw_fingerprint + await db` + UPDATE anon_identities + SET last_ip = ${auditIp}, + created_ip = COALESCE(created_ip, ${auditIp}) + ${hwFingerprint ? db`, hw_fingerprint = COALESCE(${hwFingerprint}, hw_fingerprint)` : db``} + WHERE user_id = ${userId} + `.catch(() => {}); + // 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) { + await logAnonActivity(req, { action: 'handshake', hwFingerprint }); return { session: req.cookies.session, csrf_token: req.session.csrf_token }; } @@ -161,6 +226,7 @@ export async function createAnonSession(userId, req) { `; if (existing.length > 0) { await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`; + await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint }); return { session: req.cookies.session, csrf_token: existing[0].csrf_token }; } } @@ -188,5 +254,7 @@ export async function createAnonSession(userId, req) { INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')} `; + await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint }); + return { session, csrf_token: csrfToken }; } diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index 1a54691..0a4a7cc 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -513,6 +513,7 @@ "stat_favs": "Gesamt Favoriten", "stat_disk_usage": "Dateigröße Gesamt", "stat_users": "Gesamt Benutzer", + "stat_anon_users": "Anonyme Benutzer", "most_favorited": "Meiste Favs", "favs": "Favs", "top_xd": "Top xD-Score" diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index 18a66d5..45716ac 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -518,6 +518,7 @@ "stat_favs": "Total Favorites", "stat_disk_usage": "Total File Size", "stat_users": "Total Users", + "stat_anon_users": "Anonymous Users", "most_favorited": "Most Favorited", "favs": "favs", "top_xd": "Top xD Scores" diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index 02a7045..cf09a32 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -511,6 +511,7 @@ "stat_favs": "Totaal aantal favorieten", "stat_disk_usage": "Totale Bestandsgrootte", "stat_users": "Totaal Gebruikers", + "stat_anon_users": "Anonieme gebruikers", "most_favorited": "Meest Gefavoriet", "favs": "favorieten", "top_xd": "Top xD-scores" diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index 0095e42..c63adfd 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -512,6 +512,7 @@ "stat_favs": "Gesamtanzahl Favoriten", "stat_disk_usage": "Dateigröße Gesamt", "stat_users": "Gesamt Benutzer", + "stat_anon_users": "Anonymer Alkoholiker", "most_favorited": "Am häufigsten favorisiert", "favs": "Favoriten", "top_xd": "Beste xD-Punktestände" diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 029e665..274f82e 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -2328,7 +2328,9 @@ const f0cklib = { mime, exclude_ids, session_tags = '', - session_creators = '' + session_creators = '', + continuation = false, + prefer_personalized = null } = {}) => { const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null; const modequery = computeBaseMode(mode, ratingsArr, session); @@ -2420,13 +2422,30 @@ const f0cklib = { }); } - // 2. Personalization vs Serendipity Split (60% personalized, 40% random exploration) + // 2. Personalization vs Serendipity Split + // In the sidebar suggestions: + // - 1 of the first 3 recommendations is a personalized recommendation and the other 2 are not. + // - After that, recommendations appear sporadically (~20% rate, non-consecutive with 3-5 random items between them). + const isFirstBatch = !continuation && excludeItemIds.length === 0; let personalizedTarget; + if (maxLimit === 1) { - // For single-card replacement: 65% chance personalized, 35% chance discovery - personalizedTarget = Math.random() < 0.65 ? 1 : 0; + if (prefer_personalized === true) { + personalizedTarget = 1; + } else if (prefer_personalized === false) { + personalizedTarget = 0; + } else { + personalizedTarget = Math.random() < 0.20 ? 1 : 0; + } + } else if (isFirstBatch) { + // 1 in top 3, plus sporadic recommendations in the remaining slots + const topRec = maxLimit >= 1 ? 1 : 0; + const remainingSlots = Math.max(0, maxLimit - 3); + const sporadicRecs = Math.round(remainingSlots * 0.20); + personalizedTarget = topRec + sporadicRecs; } else { - personalizedTarget = Math.round(maxLimit * 0.60); + // Continuation batch: sporadic recommendations throughout + personalizedTarget = Math.max(1, Math.round(maxLimit * 0.20)); } const targetTagIds = targetTagEntries.map(([tid]) => tid); @@ -2537,8 +2556,9 @@ const f0cklib = { let randomItems = []; if (neededRandom > 0) { const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)]; + // Fetch extra buffer of random items to ensure ample spacing randomItems = await f0cklib.getRandomRecommendations({ - limit: neededRandom, + limit: Math.max(neededRandom + 3, maxLimit), mode, ratings, session, @@ -2550,14 +2570,74 @@ const f0cklib = { }); } - // 4. Combine & Interweave with Fisher-Yates Shuffle - const combined = [...personalizedItems, ...randomItems]; - for (let i = combined.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [combined[i], combined[j]] = [combined[j], combined[i]]; + // 4. Combine & Interweave + if (maxLimit === 1) { + return personalizedItems.length > 0 ? personalizedItems.slice(0, 1) : randomItems.slice(0, 1); } - return combined.slice(0, maxLimit); + if (personalizedItems.length === 0) { + return randomItems.slice(0, maxLimit); + } + + if (randomItems.length === 0) { + return personalizedItems.slice(0, maxLimit); + } + + const result = []; + if (isFirstBatch) { + // First 3 items: exactly 1 is a recommendation, and the other 2 are not + const topCount = Math.min(3, maxLimit); + const topSlots = new Array(topCount); + const recSlot = Math.floor(Math.random() * topCount); + topSlots[recSlot] = personalizedItems.shift(); + + for (let s = 0; s < topCount; s++) { + if (s !== recSlot) { + topSlots[s] = randomItems.length > 0 ? randomItems.shift() : personalizedItems.shift(); + } + } + for (let s = 0; s < topCount; s++) { + if (topSlots[s]) result.push(topSlots[s]); + } + + // Remaining slots: recommendations come sporadically (separated by 3 to 5 non-recommendations) + let gapSinceRec = topCount - 1 - recSlot; + let targetGap = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5 random items + + while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) { + if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) { + result.push(personalizedItems.shift()); + gapSinceRec = 0; + targetGap = Math.floor(Math.random() * 3) + 3; + } else if (randomItems.length > 0) { + result.push(randomItems.shift()); + gapSinceRec++; + } else if (personalizedItems.length > 0) { + result.push(personalizedItems.shift()); + gapSinceRec = 0; + } + } + } else { + // Continuation batch: recommendations come sporadically throughout + let gapSinceRec = Math.floor(Math.random() * 2) + 1; + let targetGap = Math.floor(Math.random() * 3) + 3; + + while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) { + if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) { + result.push(personalizedItems.shift()); + gapSinceRec = 0; + targetGap = Math.floor(Math.random() * 3) + 3; + } else if (randomItems.length > 0) { + result.push(randomItems.shift()); + gapSinceRec++; + } else if (personalizedItems.length > 0) { + result.push(personalizedItems.shift()); + gapSinceRec = 0; + } + } + } + + return result.slice(0, maxLimit); }, getTagFeedItems: async ({ diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index b757a45..bd6edc5 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -323,6 +323,196 @@ export default (router, tpl) => { }); }); + router.get(/^\/admin\/bans(\/)?$/, lib.modAuth, async (req, res) => { + const bannedFingerprints = await db` + select bf.*, + u.login as anon_login, u.user as anon_user, + admin.user as banned_by_user + from banned_fingerprints bf + left join anon_identities ai on ai.fingerprint = bf.fingerprint + left join "user" u on u.id = ai.user_id + left join "user" admin on admin.id = bf.banned_by + order by bf.created_at desc + `; + + const bannedHardware = await db` + select bh.*, + admin.user as banned_by_user + from banned_hardware_fingerprints bh + left join "user" admin on admin.id = bh.banned_by + order by bh.created_at desc + `; + + const bannedIps = await db` + select bi.*, + admin.user as banned_by_user + from banned_ips bi + left join "user" admin on admin.id = bi.banned_by + order by bi.created_at desc + `; + + const anonIdentities = await db` + select ai.*, + u.id as user_id, u.login, u.user, u.banned as is_banned, u.ban_reason, + bf.id as is_fp_banned, + bh.id as is_hw_banned + from anon_identities ai + left join "user" u on u.id = ai.user_id + left join banned_fingerprints bf on bf.fingerprint = ai.fingerprint + left join banned_hardware_fingerprints bh on bh.hw_fingerprint = ai.hw_fingerprint + order by ai.last_seen desc + limit 100 + `; + + const recentActivity = await db` + select al.*, + u.login, u.user + from anon_activity_log al + left join "user" u on u.id = al.user_id + order by al.created_at desc + limit 100 + `; + + res.reply({ + body: tpl.render("admin/bans", { + session: req.session, + csrf_token: req.session ? req.session.csrf_token : '', + bannedFingerprints, + bannedHardware, + bannedIps, + anonIdentities, + recentActivity, + tmp: null + }, req) + }); + }); + + router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/ban\/?$/, lib.modAuth, async (req, res) => { + try { + const { fingerprint, reason, duration, ban_ips, user_id } = req.post || {}; + if (!fingerprint) throw new Error('Missing fingerprint'); + + const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000); + + const result = await security.banAnonymousUser({ + userId: user_id ? +user_id : null, + fingerprint, + bannedBy: req.session.id, + reason: reason || 'Banned by moderator', + expires, + banIps: ban_ips !== false + }); + + await audit.log(req.session.id, 'ban_fingerprint', 'fingerprint', null, { fingerprint, reason, duration }); + + return res.json({ success: true, result }); + } catch (err) { + return res.json({ success: false, msg: err.message }); + } + }); + + router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/unban\/?$/, lib.modAuth, async (req, res) => { + try { + const { fingerprint } = req.post || {}; + if (!fingerprint) throw new Error('Missing fingerprint'); + + await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${fingerprint}`; + + // Also unban any shadow user associated with this fingerprint + const ident = await db`SELECT user_id FROM anon_identities WHERE fingerprint = ${fingerprint}`; + if (ident.length > 0) { + await db`UPDATE "user" SET banned = false, ban_reason = null, ban_expires = null WHERE id = ${ident[0].user_id}`; + } + + await audit.log(req.session.id, 'unban_fingerprint', 'fingerprint', null, { fingerprint }); + + return res.json({ success: true }); + } catch (err) { + return res.json({ success: false, msg: err.message }); + } + }); + + router.post(/^\/api\/v2\/admin\/bans\/ip\/ban\/?$/, lib.modAuth, async (req, res) => { + try { + const { ip, reason, duration } = req.post || {}; + if (!ip) throw new Error('Missing IP address'); + + const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000); + const ipHash = security.hashIP(ip); + + await db` + INSERT INTO banned_ips (ip, ip_hash, banned_by, reason, expires_at) + VALUES (${ip}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires}) + ON CONFLICT (ip) DO UPDATE + SET reason = EXCLUDED.reason, + expires_at = EXCLUDED.expires_at, + banned_by = EXCLUDED.banned_by, + ip_hash = EXCLUDED.ip_hash + `; + + await audit.log(req.session.id, 'ban_ip', 'ip', null, { ip, reason, duration }); + + return res.json({ success: true }); + } catch (err) { + return res.json({ success: false, msg: err.message }); + } + }); + + router.post(/^\/api\/v2\/admin\/bans\/ip\/unban\/?$/, lib.modAuth, async (req, res) => { + try { + const { ip } = req.post || {}; + if (!ip) throw new Error('Missing IP'); + + await db`DELETE FROM banned_ips WHERE ip = ${ip} OR ip_hash = ${ip}`; + + await audit.log(req.session.id, 'unban_ip', 'ip', null, { ip }); + + return res.json({ success: true }); + } catch (err) { + return res.json({ success: false, msg: err.message }); + } + }); + + router.post(/^\/api\/v2\/admin\/bans\/hardware\/ban\/?$/, lib.modAuth, async (req, res) => { + try { + const { hw_fingerprint, reason, duration, ban_ips, user_id } = req.post || {}; + if (!hw_fingerprint) throw new Error('Missing hardware fingerprint'); + + const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000); + + const result = await security.banAnonymousUser({ + userId: user_id ? +user_id : null, + hwFingerprint: hw_fingerprint, + bannedBy: req.session.id, + reason: reason || 'Banned by moderator', + expires, + banIps: ban_ips !== false, + banHardware: true + }); + + await audit.log(req.session.id, 'ban_hardware', 'hardware', null, { hw_fingerprint, reason, duration }); + + return res.json({ success: true, result }); + } catch (err) { + return res.json({ success: false, msg: err.message }); + } + }); + + router.post(/^\/api\/v2\/admin\/bans\/hardware\/unban\/?$/, lib.modAuth, async (req, res) => { + try { + const { hw_fingerprint } = req.post || {}; + if (!hw_fingerprint) throw new Error('Missing hardware fingerprint'); + + await db`DELETE FROM banned_hardware_fingerprints WHERE hw_fingerprint = ${hw_fingerprint}`; + + await audit.log(req.session.id, 'unban_hardware', 'hardware', null, { hw_fingerprint }); + + return res.json({ success: true }); + } catch (err) { + return res.json({ success: false, msg: err.message }); + } + }); + router.get(/^\/admin\/user\/(?\d+)\/ips(\/)?$/, lib.auth, async (req, res) => { const userId = +req.params.userId; const user = await db`select "user", login from "user" where id = ${userId} limit 1`; @@ -481,6 +671,19 @@ export default (router, tpl) => { where id = ${+user_id} `; + // If this is an anonymous identity, cascade the ban to fingerprint and IPs + const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`; + if (anonIdent.length > 0 || (targetUser[0].login && targetUser[0].login.startsWith('anon_'))) { + await security.banAnonymousUser({ + userId: +user_id, + fingerprint: anonIdent[0]?.fingerprint || null, + bannedBy: req.session.id, + reason, + expires, + banIps: true + }); + } + // Log it in audit await audit.log(req.session.id, 'ban_user', 'user', +user_id, { reason, duration, target_user: targetUser[0].user }); @@ -516,6 +719,12 @@ export default (router, tpl) => { where id = ${+user_id} `; + // Clean up any banned fingerprint for this identity + const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`; + if (anonIdent.length > 0) { + await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${anonIdent[0].fingerprint}`; + } + // Log it in audit await audit.log(req.session.id, 'unban_user', 'user', +user_id); diff --git a/src/inc/routes/apiv2/anon.mjs b/src/inc/routes/apiv2/anon.mjs index a1bf1cf..2f6ac7f 100644 --- a/src/inc/routes/apiv2/anon.mjs +++ b/src/inc/routes/apiv2/anon.mjs @@ -1,6 +1,7 @@ import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; +import security from '../../security.mjs'; import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs'; import { getEnableAnonymousAccess } from '../../settings.mjs'; @@ -17,6 +18,19 @@ export default router => { return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); } + const clientIp = security.getRealIP(req); + const ipBan = await security.isIpBanned(clientIp); + if (ipBan) { + return res.json({ + success: false, + banned: true, + msg: 'YOU ARE BANNED!', + reason: ipBan.reason || 'IP address is banned', + expires: ipBan.expires ? new Date(ipBan.expires).toLocaleString() : 'Permanent', + redirect: '/banned' + }, 403); + } + const body = req.post || req.body || {}; const pubkey = (body.pubkey || '').trim(); const timestamp = parseInt(body.timestamp, 10); @@ -39,8 +53,119 @@ export default router => { } const parsed = parseOpenSshPubkey(pubkey); - const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint); - const { session, csrf_token } = await createAnonSession(userId, req); + const hwFingerprint = (body.hw_fingerprint || '').trim() || null; + + // Check tombstone token sent from client (fingerprint or hardware ID) + if (body.tombstone && body.tombstone.banned) { + const tombstoneFp = body.tombstone.fingerprint; + const tombstoneHw = body.tombstone.hw_fingerprint; + const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null; + const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null; + const activeTombstoneBan = tombstoneBan || tombstoneHwBan; + + if (activeTombstoneBan) { + await security.banAnonymousUser({ + fingerprint: parsed.fingerprint, + hwFingerprint: hwFingerprint || tombstoneHw, + bannedBy: activeTombstoneBan.banned_by, + reason: `Cascade ban from device (${activeTombstoneBan.reason || 'Banned'})`, + expires: activeTombstoneBan.expires, + banIps: true, + banHardware: true + }); + return res.json({ + success: false, + banned: true, + fingerprint: parsed.fingerprint, + hw_fingerprint: hwFingerprint || tombstoneHw, + msg: 'YOU ARE BANNED!', + reason: activeTombstoneBan.reason || 'Device is banned', + expires: activeTombstoneBan.expires ? new Date(activeTombstoneBan.expires).toLocaleString() : 'Permanent', + redirect: '/banned' + }, 403); + } + } + + // Check hardware fingerprint ban + if (hwFingerprint) { + const hwBan = await security.isHardwareBanned(hwFingerprint); + if (hwBan) { + await security.banAnonymousUser({ + fingerprint: parsed.fingerprint, + hwFingerprint, + bannedBy: hwBan.banned_by, + reason: `Cascade ban from hardware ID (${hwBan.reason || 'Banned'})`, + expires: hwBan.expires, + banIps: true, + banHardware: true + }); + return res.json({ + success: false, + banned: true, + fingerprint: parsed.fingerprint, + hw_fingerprint: hwFingerprint, + msg: 'YOU ARE BANNED!', + reason: hwBan.reason || 'Hardware ID is banned', + expires: hwBan.expires ? new Date(hwBan.expires).toLocaleString() : 'Permanent', + redirect: '/banned' + }, 403); + } + } + + // Check fingerprint ban + const fpBan = await security.isFingerprintBanned(parsed.fingerprint); + if (fpBan) { + if (hwFingerprint) { + await security.banAnonymousUser({ + fingerprint: parsed.fingerprint, + hwFingerprint, + bannedBy: fpBan.banned_by, + reason: `Cascade ban from key (${fpBan.reason || 'Banned'})`, + expires: fpBan.expires, + banIps: true, + banHardware: true + }); + } + return res.json({ + success: false, + banned: true, + fingerprint: parsed.fingerprint, + hw_fingerprint: hwFingerprint, + msg: 'YOU ARE BANNED!', + reason: fpBan.reason || 'Key fingerprint is banned', + expires: fpBan.expires ? new Date(fpBan.expires).toLocaleString() : 'Permanent', + redirect: '/banned' + }, 403); + } + + const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint, req, hwFingerprint); + + // Check user table ban + const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`; + if (userRows.length > 0 && userRows[0].banned) { + const u = userRows[0]; + await security.banAnonymousUser({ + userId, + fingerprint: parsed.fingerprint, + hwFingerprint, + reason: u.ban_reason || 'Banned', + expires: u.ban_expires, + banIps: true, + banHardware: true + }); + return res.json({ + success: false, + banned: true, + fingerprint: parsed.fingerprint, + hw_fingerprint: hwFingerprint, + msg: 'YOU ARE BANNED!', + reason: u.ban_reason || 'Banned', + expires: u.ban_expires ? new Date(u.ban_expires).toLocaleString() : 'Permanent', + redirect: '/banned' + }, 403); + } + + const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint); res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`); @@ -50,6 +175,7 @@ export default router => { user_id: userId, fingerprint: parsed.fingerprint, short_fingerprint: parsed.shortFingerprint, + hw_fingerprint: hwFingerprint, csrf_token: csrf_token }); } catch (err) { @@ -73,7 +199,7 @@ export default router => { } const rows = await db` - SELECT pubkey, fingerprint, created_at, last_seen + SELECT pubkey, fingerprint, hw_fingerprint, created_at, last_seen FROM anon_identities WHERE user_id = ${req.session.id} LIMIT 1 @@ -87,6 +213,7 @@ export default router => { user_id: req.session.id, fingerprint: fp, short_fingerprint: fp.slice(7, 15), + hw_fingerprint: rows[0].hw_fingerprint, pubkey: rows[0].pubkey, csrf_token: req.session.csrf_token }); diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index b8a48b9..7ab53aa 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -12,6 +12,7 @@ import { parseMultipart, collectBody } from '../../multipart.mjs'; import { purgeExpiredUploads } from '../../lib_delete.mjs'; import { calculateExpiresAt } from './upload.mjs'; import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs'; +import { logAnonActivity } from '../../anon_auth.mjs'; const allowedMimes = ["audio", "image", "video", "%"]; const getGlobalfilter = () => { @@ -730,6 +731,13 @@ export default router => { const sessionTags = req.url.qs?.session_tags || ''; const sessionCreators = req.url.qs?.session_creators || ''; + const continuation = req.url.qs?.continuation === '1' || req.url.qs?.continuation === 'true'; + let preferPersonalized = null; + if (req.url.qs?.prefer_personalized === 'true' || req.url.qs?.prefer_personalized === '1') { + preferPersonalized = true; + } else if (req.url.qs?.prefer_personalized === 'false' || req.url.qs?.prefer_personalized === '0') { + preferPersonalized = false; + } const items = await f0cklib.getPersonalizedRecommendations({ limit, @@ -742,7 +750,9 @@ export default router => { mime, exclude_ids: excludeIds, session_tags: sessionTags, - session_creators: sessionCreators + session_creators: sessionCreators, + continuation, + prefer_personalized: preferPersonalized }); res.json({ @@ -1381,6 +1391,9 @@ export default router => { and item_id = ${+postid} `; f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: -5.0 }).catch(() => {}); + if (req.session?.is_anon) { + await logAnonActivity(req, { action: 'unfavorite', targetId: postid }); + } } else { // add fav — ON CONFLICT DO NOTHING guards against rapid double-taps await db` @@ -1391,6 +1404,9 @@ export default router => { on conflict do nothing `; f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: 5.0 }).catch(() => {}); + if (req.session?.is_anon) { + await logAnonActivity(req, { action: 'favorite', targetId: postid }); + } } const favs = await db` @@ -1452,6 +1468,10 @@ export default router => { } } + if (req.session?.is_anon) { + await logAnonActivity(req, { action: 'favorites_import', details: { count } }); + } + return res.json({ success: true, imported: count }); } catch (err) { console.error('[FAVORITES_IMPORT_ERROR]', err); diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs index 7d189c4..24ee81b 100644 --- a/src/inc/routes/apiv2/tags.mjs +++ b/src/inc/routes/apiv2/tags.mjs @@ -5,6 +5,7 @@ import queue from "../../queue.mjs"; import cfg from "../../config.mjs"; import fs from "fs"; import path from "path"; +import { logAnonActivity } from "../../anon_auth.mjs"; export default router => { router.group(/^\/api\/v2\/tags\/(?\d+)/, group => { @@ -86,6 +87,13 @@ export default router => { } const freshTags = await lib.getTags(postid); + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'tag', + targetId: postid, + details: { tag: tagname } + }); + } console.log(`[API] Notifying 'tags' for item ${postid} with ${freshTags.length} tags`); await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })); @@ -176,6 +184,13 @@ export default router => { const { label, cls } = labels[nextTagId] || { label: 'SFW', cls: 'sfw' }; await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }).catch(() => {}); + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'cycle_rating', + targetId: postid, + details: { from: ratingTagId, to: nextTagId, rating_label: label } + }); + } const freshTags = await lib.getTags(postid); await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {}); @@ -281,6 +296,13 @@ export default router => { if (reply) { const reason = req.post.reason || req.url.qs.reason || 'No reason provided'; await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, reason }); + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'delete_tag', + targetId: postid, + details: { tag: tagname, reason } + }); + } } const freshTags = await lib.getTags(postid); diff --git a/src/inc/routes/banned.mjs b/src/inc/routes/banned.mjs index ea75a6d..ac0fb6b 100644 --- a/src/inc/routes/banned.mjs +++ b/src/inc/routes/banned.mjs @@ -1,8 +1,37 @@ import cfg from "../config.mjs"; +import security from "../security.mjs"; export default (router, tpl) => { router.get(/^\/banned\/?$/, async (req, res) => { - if (!req.session || !req.session.banned) { + let isBanned = false; + let reason = 'Violation of community rules'; + let expires = null; + + if (req.session && req.session.banned) { + isBanned = true; + reason = req.session.ban_reason || reason; + expires = req.session.ban_expires; + } + + const clientIp = security.getRealIP(req); + const ipBan = await security.isIpBanned(clientIp); + if (ipBan) { + isBanned = true; + reason = ipBan.reason || reason; + expires = ipBan.expires; + } + + const fp = req.session?.fingerprint || req.session?.anon_fingerprint; + if (fp) { + const fpBan = await security.isFingerprintBanned(fp); + if (fpBan) { + isBanned = true; + reason = fpBan.reason || reason; + expires = fpBan.expires; + } + } + + if (!isBanned) { return res.writeHead(302, { "Location": "/" }).end(); @@ -11,8 +40,8 @@ export default (router, tpl) => { res.reply({ body: tpl.render("banned", { session: req.session, - reason: req.session.ban_reason, - expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent', + reason: reason, + expires: expires ? new Date(expires).toLocaleString() : 'Permanent', ban_video: cfg.websrv.ban_video, hideNavbar: true }, req) diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index 1fdb933..9286442 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -7,7 +7,7 @@ import audit from "../audit.mjs"; import { promises as fs } from "fs"; import { applyWordFilter } from "../wordfilter.mjs"; import path from "path"; -import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } from "../anon_auth.mjs"; +import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs"; import { getEnableAnonymousAccess } from "../settings.mjs"; export default (router, tpl) => { @@ -457,11 +457,13 @@ export default (router, tpl) => { } } + const auditIp = resolveAuditIP(req); const insertData = { item_id, user_id: req.session.id, parent_id: parent_id || null, - content: content || '' + content: content || '', + ip: auditIp }; if (video_time !== null) insertData.video_time = video_time; @@ -472,6 +474,14 @@ export default (router, tpl) => { const commentId = parseInt(newComment[0].id, 10); + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'comment', + targetId: commentId, + details: { item_id, parent_id } + }); + } + // Link uploaded files to this comment (if any) let activityFiles = []; const fileIdsRaw = body.file_ids || ''; @@ -785,6 +795,14 @@ export default (router, tpl) => { old_content: comment[0].content }); + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'comment_delete', + targetId: commentId, + details: { item_id: comment[0].item_id } + }); + } + // Handle attachments cleanup const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`; for (const f of files) { @@ -1451,6 +1469,14 @@ export default (router, tpl) => { `; } + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'poll_vote', + targetId: pollId, + details: { option_id: optionId } + }); + } + // Return updated tally const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`; const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true; diff --git a/src/inc/routes/ranking.mjs b/src/inc/routes/ranking.mjs index 26e3070..c46e7ac 100644 --- a/src/inc/routes/ranking.mjs +++ b/src/inc/routes/ranking.mjs @@ -59,6 +59,11 @@ export default (router, tpl) => { from "tags_assign" left join "user" on "user".id = "tags_assign".user_id left join "user_options" on "user_options".user_id = "user".id + left join "anon_identities" on "anon_identities".user_id = "user".id + where "user".id is not null + and "anon_identities".id is null + and "user".login not like 'anon_%' + and "user".user != 'anonymous' group by "user".user, "user_options".avatar, "user_options".avatar_file, "user".admin, "user_options".display_name order by count desc `; @@ -81,6 +86,15 @@ export default (router, tpl) => { const totalUsers = +(await db` select count(*) as total from "user" + left join "anon_identities" on "anon_identities".user_id = "user".id + where "anon_identities".id is null + and "user".login not like 'anon_%' + and "user".user != 'anonymous' + `)[0].total; + + const totalAnonUsers = +(await db` + select count(distinct pubkey) as total + from anon_identities `)[0].total; const hoster = await db` @@ -141,6 +155,7 @@ export default (router, tpl) => { totalComments, totalFavs, totalUsers, + totalAnonUsers, enable_nsfl: config.enable_nsfl, diskSize: cachedDiskSize, tmp: null, diff --git a/src/inc/security.mjs b/src/inc/security.mjs index 25cc2ce..9d8f6cd 100644 --- a/src/inc/security.mjs +++ b/src/inc/security.mjs @@ -190,4 +190,209 @@ export default new class { on conflict (user_id, ip) do update set last_seen = now() `.catch(err => console.error(`[SECURITY] Failed to log user IP:`, err)); } + + /** + * Check if an IP is banned in banned_ips. + * Checks both raw IP and hashed IP. + * @param {string} ip + * @returns {Promise<{ id: number, ip_address: string, reason: string, expires: Date|null }|null>} + */ + async isIpBanned(ip) { + if (!ip || ip === "unknown") return null; + if (cfg.main.development && ip === "127.0.0.1" && !cfg.test_ban_localhost) return null; + try { + const ipHash = this.hashIP(ip); + const rows = await db` + select id, ip, ip_hash, reason, expires_at as expires + from banned_ips + where (ip = ${ip} or ip = ${ipHash} or ip_hash = ${ipHash} or ip_hash = ${ip}) + and (expires_at is null or expires_at > now()) + limit 1 + `; + return rows.length > 0 ? rows[0] : null; + } catch (err) { + console.error('[SECURITY] Error checking isIpBanned:', err); + return null; + } + } + + /** + * Check if an OpenSSH fingerprint is banned in banned_fingerprints. + * @param {string} fingerprint + * @returns {Promise<{ id: number, fingerprint: string, reason: string, expires: Date|null, banned_by: number|null }|null>} + */ + async isFingerprintBanned(fingerprint) { + if (!fingerprint) return null; + try { + const rows = await db` + select id, fingerprint, reason, expires_at as expires, banned_by + from banned_fingerprints + where fingerprint = ${fingerprint} + and (expires_at is null or expires_at > now()) + limit 1 + `; + return rows.length > 0 ? rows[0] : null; + } catch (err) { + console.error('[SECURITY] Error checking isFingerprintBanned:', err); + return null; + } + } + + /** + * Check if a hardware fingerprint is banned in banned_hardware_fingerprints. + * @param {string} hwFingerprint + * @returns {Promise<{ id: number, hw_fingerprint: string, reason: string, expires: Date|null, banned_by: number|null }|null>} + */ + async isHardwareBanned(hwFingerprint) { + if (!hwFingerprint) return null; + try { + const rows = await db` + select id, hw_fingerprint, reason, expires_at as expires, banned_by + from banned_hardware_fingerprints + where hw_fingerprint = ${hwFingerprint} + and (expires_at is null or expires_at > now()) + limit 1 + `; + return rows.length > 0 ? rows[0] : null; + } catch (err) { + console.error('[SECURITY] Error checking isHardwareBanned:', err); + return null; + } + } + + /** + * Comprehensive anonymous user ban: + * - Marks the shadow user in "user" table as banned + * - Records fingerprint in banned_fingerprints + * - Records hardware fingerprint in banned_hardware_fingerprints + * - Discovers all associated IPs (from anon_activity_log, user_ips, anon_identities, comments) and inserts into banned_ips + * - Destroys active sessions + */ + async banAnonymousUser({ userId = null, fingerprint = null, hwFingerprint = null, bannedBy = null, reason = 'Banned anonymous identity', expires = null, banIps = true, banHardware = true } = {}) { + let targetFingerprint = fingerprint; + let targetHwFingerprint = hwFingerprint; + + if (userId && !targetFingerprint) { + const row = (await db`select fingerprint from anon_identities where user_id = ${userId} limit 1`)[0]; + if (row) targetFingerprint = row.fingerprint; + } + + if (userId && !targetHwFingerprint) { + const row = (await db`select hw_fingerprint from anon_identities where user_id = ${userId} and hw_fingerprint is not null limit 1`)[0]; + if (row) targetHwFingerprint = row.hw_fingerprint; + } + + if (targetFingerprint && !userId) { + const row = (await db`select user_id from anon_identities where fingerprint = ${targetFingerprint} limit 1`)[0]; + if (row) userId = row.user_id; + } + + if (targetFingerprint && !targetHwFingerprint) { + const row = (await db`select hw_fingerprint from anon_identities where fingerprint = ${targetFingerprint} and hw_fingerprint is not null limit 1`)[0]; + if (row) targetHwFingerprint = row.hw_fingerprint; + } + + // 1. Ban the user account + if (userId) { + await db` + update "user" + set banned = true, + ban_reason = ${reason}, + ban_expires = ${expires} + where id = ${userId} + `; + await db`delete from "user_sessions" where user_id = ${userId}`; + } + + // 2. Ban the fingerprint + if (targetFingerprint) { + await db` + insert into banned_fingerprints (fingerprint, banned_by, reason, expires_at) + values (${targetFingerprint}, ${bannedBy}, ${reason}, ${expires}) + on conflict (fingerprint) do update + set reason = excluded.reason, + expires_at = excluded.expires_at, + banned_by = excluded.banned_by + `; + } + + // 3. Ban the hardware fingerprint + if (banHardware) { + const associatedHws = new Set(); + if (targetHwFingerprint) associatedHws.add(targetHwFingerprint); + + if (userId) { + const actHws = await db`select distinct hw_fingerprint from anon_activity_log where user_id = ${userId} and hw_fingerprint is not null`; + for (const r of actHws) if (r.hw_fingerprint) associatedHws.add(r.hw_fingerprint); + + const identHws = await db`select hw_fingerprint from anon_identities where user_id = ${userId} and hw_fingerprint is not null`; + for (const r of identHws) if (r.hw_fingerprint) associatedHws.add(r.hw_fingerprint); + } + + if (targetFingerprint) { + const actHws = await db`select distinct hw_fingerprint from anon_activity_log where fingerprint = ${targetFingerprint} and hw_fingerprint is not null`; + for (const r of actHws) if (r.hw_fingerprint) associatedHws.add(r.hw_fingerprint); + } + + for (const hw of associatedHws) { + if (!hw) continue; + await db` + insert into banned_hardware_fingerprints (hw_fingerprint, banned_by, reason, expires_at) + values (${hw}, ${bannedBy}, ${reason}, ${expires}) + on conflict (hw_fingerprint) do update + set reason = excluded.reason, + expires_at = excluded.expires_at, + banned_by = excluded.banned_by + `.catch(err => console.error('[SECURITY] Error inserting banned hardware fingerprint:', err)); + } + } + + // 4. Cascade to associated IPs + if (banIps) { + const associatedIps = new Set(); + + if (userId) { + const actIps = await db`select distinct ip from anon_activity_log where user_id = ${userId}`; + for (const r of actIps) if (r.ip) associatedIps.add(r.ip); + + const uIps = await db`select distinct ip from user_ips where user_id = ${userId}`; + for (const r of uIps) if (r.ip) associatedIps.add(r.ip); + + const cIps = await db`select distinct ip from comments where user_id = ${userId} and ip is not null`; + for (const r of cIps) if (r.ip) associatedIps.add(r.ip); + } + + if (targetFingerprint) { + const actIps = await db`select distinct ip from anon_activity_log where fingerprint = ${targetFingerprint}`; + for (const r of actIps) if (r.ip) associatedIps.add(r.ip); + + const identIps = await db`select created_ip, last_ip from anon_identities where fingerprint = ${targetFingerprint}`; + for (const r of identIps) { + if (r.created_ip) associatedIps.add(r.created_ip); + if (r.last_ip) associatedIps.add(r.last_ip); + } + } + + if (targetHwFingerprint) { + const actIps = await db`select distinct ip from anon_activity_log where hw_fingerprint = ${targetHwFingerprint}`; + for (const r of actIps) if (r.ip) associatedIps.add(r.ip); + } + + for (const ip of associatedIps) { + if (!ip || ip === 'unknown') continue; + const ipHash = this.hashIP(ip); + await db` + insert into banned_ips (ip, ip_hash, banned_by, reason, expires_at) + values (${ip}, ${ipHash}, ${bannedBy}, ${reason}, ${expires}) + on conflict (ip) do update + set reason = excluded.reason, + expires_at = excluded.expires_at, + banned_by = excluded.banned_by, + ip_hash = excluded.ip_hash + `.catch(err => console.error('[SECURITY] Error inserting banned IP:', err)); + } + } + + return { success: true, userId, fingerprint: targetFingerprint, hwFingerprint: targetHwFingerprint }; + } }; diff --git a/src/index.mjs b/src/index.mjs index 8e2d3b2..45fba1c 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -867,14 +867,55 @@ process.on('uncaughtException', err => { } // csrf_token is loaded from user_sessions table via the session query above - // Ban check - if (req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) { + // Ban check (Session) + if (req.session && req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) { const now = new Date(); if (req.session.ban_expires && new Date(req.session.ban_expires) < now) { // Ban expired, lift it await db`update "user" set banned = false, ban_reason = null, ban_expires = null where id = ${+req.session.id}`; req.session.banned = false; } else { + if (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.url.pathname.startsWith('/api/')) { + res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: false, + banned: true, + msg: 'YOU ARE BANNED!', + reason: req.session.ban_reason || 'Banned', + expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent', + redirect: '/banned' + })); + req.url.pathname = '/ban_redirect_bypass'; + return; + } + res.writeHead(307, { + "Location": "/banned" + }).end(); + req.url.pathname = '/ban_redirect_bypass'; + return; + } + } + + // Ban check (IP and Fingerprint - applies to all requests, even without active session) + if (!req.url.pathname.match(/^\/(banned|logout|s\/|a\/|t\/|b\/|c\/|favicon\.ico)(\/)?$/)) { + const clientIp = security.getRealIP(req); + const ipBan = await security.isIpBanned(clientIp); + const fp = req.session?.fingerprint || req.session?.anon_fingerprint; + const fpBan = fp ? await security.isFingerprintBanned(fp) : null; + const banInfo = ipBan || fpBan; + + if (banInfo) { + if (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.url.pathname.startsWith('/api/')) { + res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: false, + banned: true, + msg: 'YOU ARE BANNED!', + reason: banInfo.reason || 'Banned', + expires: banInfo.expires ? new Date(banInfo.expires).toLocaleString() : 'Permanent', + redirect: '/banned' + })); + req.url.pathname = '/ban_redirect_bypass'; + return; + } res.writeHead(307, { "Location": "/banned" }).end(); diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index db6f5fb..28781d7 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -11,6 +11,7 @@ import { parseMultipart, collectBody } from "./inc/multipart.mjs"; import f0cklib from "./inc/routeinc/f0cklib.mjs"; import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs"; import { addPrivateItem } from "./inc/private_items.mjs"; +import { resolveAuditIP, logAnonActivity } from "./inc/anon_auth.mjs"; // Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf. @@ -344,6 +345,7 @@ export const handleUpload = async (req, res, self) => { const ytUrl = `https://www.youtube.com/watch?v=${videoId}`; const filename = `yt:${videoId}`; + const auditIp = resolveAuditIP(req); const [{ id: itemid }] = await db` insert into items ${db({ src: ytUrl, @@ -361,11 +363,20 @@ export const handleUpload = async (req, res, self) => { title: title, visibility: targetVisibility, slug: itemSlug, - expires_at: targetExpiresAt - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')} + expires_at: targetExpiresAt, + uploader_ip: auditIp + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at', 'uploader_ip')} RETURNING id `; + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'upload', + targetId: itemid, + details: { type: 'youtube', videoId, slug: itemSlug } + }); + } + if (targetVisibility === 2) { addPrivateItem(itemid, filename, req.session.user); } @@ -741,6 +752,7 @@ export const handleUpload = async (req, res, self) => { } catch (e) {} } + const auditIp = resolveAuditIP(req); await db` insert into items ${db({ src: inputUrl || '', @@ -761,12 +773,21 @@ export const handleUpload = async (req, res, self) => { height: itemHeight, visibility: targetVisibility, slug: itemSlug, - expires_at: targetExpiresAt - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')} + expires_at: targetExpiresAt, + uploader_ip: auditIp + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at', 'uploader_ip')} `; const itemid = await queue.getItemID(filename); + if (req.session?.is_anon) { + await logAnonActivity(req, { + action: 'upload', + targetId: itemid, + details: { filename, mime: actualMime, slug: itemSlug } + }); + } + if (targetVisibility === 2) { addPrivateItem(itemid, filename, req.session.user); } diff --git a/views/admin.html b/views/admin.html index 80e1728..bb1c1a4 100644 --- a/views/admin.html +++ b/views/admin.html @@ -16,6 +16,7 @@
  • Sessions
  • Invite Tokens
  • User Manager
  • +
  • Ban & Fingerprint Manager
  • Emoji Manager
  • Meme Manager
  • Köpfe Manager
  • diff --git a/views/admin/bans.html b/views/admin/bans.html new file mode 100644 index 0000000..ef40da1 --- /dev/null +++ b/views/admin/bans.html @@ -0,0 +1,788 @@ +@include(snippets/header) + +
    +
    +
    +
    + + Back to Admin Dashboard + +
    +
    +

    + Ban & Fingerprint Manager +

    +

    + Inspect, track, and manage OpenSSH key fingerprints, IP bans, anonymous identities, and audit activity. +

    +
    +
    + + + +
    +
    +
    + + +
    +
    +
    Banned Fingerprints
    +
    {{ bannedFingerprints ? bannedFingerprints.length : 0 }}
    +
    +
    +
    Banned Hardware IDs
    +
    {{ bannedHardware ? bannedHardware.length : 0 }}
    +
    +
    +
    Banned IP Addresses
    +
    {{ bannedIps ? bannedIps.length : 0 }}
    +
    +
    +
    Known Anonymous Keys
    +
    {{ anonIdentities ? anonIdentities.length : 0 }}
    +
    +
    +
    Recent Logged Actions
    +
    {{ recentActivity ? recentActivity.length : 0 }}
    +
    +
    + + +
    + + + + + +
    + + +
    +
    + + + + + + + + + + + + + + @if(bannedFingerprints && bannedFingerprints.length > 0) + @each(bannedFingerprints as fp) + + + + + + + + + + @endeach + @else + + + + @endif + +
    FingerprintLinked AccountReasonBanned ByDateExpiresAction
    +
    + + {{ fp.fingerprint }} + + +
    +
    + @if(fp.anon_login) + + {!! fp.anon_user || fp.anon_login !!} + + @else + + @endif + + {{ fp.reason || 'No reason specified' }} + + {{ fp.banned_by_user || 'System / Admin' }} + + {{ fp.created_at ? new Date(fp.created_at).toLocaleString() : '—' }} + + @if(fp.expires_at) + {{ new Date(fp.expires_at).toLocaleString() }} + @else + Permanent + @endif + + +
    + No banned fingerprints. +
    +
    +
    + + + + + + + + + + + + + +
    +
    +
    + + + + + + + + +@include(snippets/footer) diff --git a/views/ranking.html b/views/ranking.html index 6cd9279..3103adc 100644 --- a/views/ranking.html +++ b/views/ranking.html @@ -74,6 +74,7 @@ {{ t('ranking.stat_total') }}{{ stats.total }} {{ t('ranking.stat_comments') }}{{ totalComments }} {{ t('ranking.stat_users') }}{{ totalUsers }} + {{ t('ranking.stat_anon_users') }}{{ totalAnonUsers }} {{ t('ranking.stat_favs') }}{{ totalFavs }} {{ t('ranking.stat_tagged') }}{{ stats.tagged }} {{ t('ranking.stat_untagged') }}{{ stats.untagged }} diff --git a/views/user-partial.html b/views/user-partial.html index d59df3e..b943f24 100644 --- a/views/user-partial.html +++ b/views/user-partial.html @@ -1,6 +1,6 @@
    - @if(session && session.id === user.user_id && user_banner_enabled) + @if(session && session.id === user.user_id && !session.is_anon && user_banner_enabled) - @if(session && session.id === user.user_id) + @if(session && session.id === user.user_id && !session.is_anon) @endif
    @@ -49,13 +49,13 @@
    @if(user.admin)⚡ @elseif(user.is_moderator)🛡 @endif{!! user.display_name || user.user !!}@if(user.is_ghost) LEGACY@endif @if(session && user.banned) BANNED@endif@if(user.display_name) ({!! user.user !!})@endif - @if(session && session.id === user.user_id) + @if(session && session.id === user.user_id && !session.is_anon) @endif
    - @if(session && session.id === user.user_id) + @if(session && session.id === user.user_id && !session.is_anon)