This commit is contained in:
2026-09-12 21:55:40 +02:00
parent 53055ea5c6
commit 90860b9279
24 changed files with 1958 additions and 45 deletions
+48
View File
@@ -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);
@@ -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);
+138 -2
View File
@@ -83,6 +83,102 @@
this.rawPub = null; this.rawPub = null;
this.rawSeed = null; this.rawSeed = null;
this.isSessionReady = false; 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() { getDomain() {
@@ -430,6 +526,8 @@
const timestamp = Date.now(); const timestamp = Date.now();
const message = `anon-auth:${timestamp}:${this.pubkey}`; const message = `anon-auth:${timestamp}:${this.pubkey}`;
const signature = await this.sign(message); const signature = await this.sign(message);
const tombstone = this.getTombstone();
const hwFingerprint = await this.getHardwareFingerprint();
const res = await fetch('/api/v2/anon/session', { const res = await fetch('/api/v2/anon/session', {
method: 'POST', method: 'POST',
@@ -437,11 +535,27 @@
body: JSON.stringify({ body: JSON.stringify({
pubkey: this.pubkey, pubkey: this.pubkey,
timestamp: timestamp, timestamp: timestamp,
signature: signature signature: signature,
tombstone: tombstone,
hw_fingerprint: hwFingerprint
}) })
}); });
const data = await res.json(); 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) { if (data.success) {
this.isSessionReady = true; this.isSessionReady = true;
if (data.csrf_token) { if (data.csrf_token) {
@@ -452,7 +566,8 @@
window.f0ckAnonIdentity = { window.f0ckAnonIdentity = {
userId: data.user_id, userId: data.user_id,
fingerprint: data.fingerprint, 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 })); 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 * Trigger browser download of id_ed25519 private key file
*/ */
+48 -2
View File
@@ -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 loadRecommendations = async (silent = false) => {
const container = document.getElementById('sidebar-recommendations-container'); const container = document.getElementById('sidebar-recommendations-container');
if (!container || recommendationsLoading) return; if (!container || recommendationsLoading) return;
@@ -1316,6 +1343,7 @@
const items = data.items || data.videos || []; const items = data.items || data.videos || [];
if (data.success && items.length > 0) { if (data.success && items.length > 0) {
enforceSidebarRecommendationDistribution(items);
currentRecommendations = items; currentRecommendations = items;
recommendationsLoaded = true; recommendationsLoaded = true;
@@ -1368,7 +1396,7 @@
const affParams = getSessionAffinityParams(); const affParams = getSessionAffinityParams();
const mime = getCurrentMimeFilter(); const mime = getCurrentMimeFilter();
const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : ''; 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' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await res.json(); const data = await res.json();
@@ -1877,10 +1905,28 @@
} }
const excludeArr = Array.from(excludeSet).slice(-120); 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 affParams = getSessionAffinityParams();
const mime = getCurrentMimeFilter(); const mime = getCurrentMimeFilter();
const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : ''; 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' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
const data = await res.json(); const data = await res.json();
+74 -6
View File
@@ -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. * Find or create a shadow user in the database for an anonymous SSH identity.
* @param {string} pubkey * @param {string} pubkey
* @param {string} fingerprint * @param {string} fingerprint
* @param {object} [req]
* @param {string} [hwFingerprint]
* @returns {Promise<{ userId: number, isNew: boolean }>} * @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 normPubkey = pubkey.trim();
const auditIp = req ? resolveAuditIP(req) : null;
const existing = await db` const existing = await db`
SELECT user_id FROM anon_identities SELECT user_id FROM anon_identities
WHERE pubkey = ${normPubkey} WHERE pubkey = ${normPubkey}
@@ -101,7 +144,13 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) {
`; `;
if (existing.length > 0) { 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 }; return { userId: existing[0].user_id, isNew: false };
} }
@@ -131,9 +180,12 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) {
`; `;
await db` await db`
INSERT INTO anon_identities (user_id, pubkey, fingerprint) INSERT INTO anon_identities (user_id, pubkey, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${normPubkey}, ${fingerprint}) VALUES (${userId}, ${normPubkey}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
ON CONFLICT (pubkey) DO NOTHING 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 }; 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. * Create a valid session in user_sessions for this anonymous user.
* @param {number} userId * @param {number} userId
* @param {object} req * @param {object} req
* @param {string} [hwFingerprint]
* @returns {Promise<{ session: string, csrf_token: string }>} * @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! // 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) { 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 }; 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) { if (existing.length > 0) {
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`; 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 }; 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')} 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 }; return { session, csrf_token: csrfToken };
} }
+1
View File
@@ -513,6 +513,7 @@
"stat_favs": "Gesamt Favoriten", "stat_favs": "Gesamt Favoriten",
"stat_disk_usage": "Dateigröße Gesamt", "stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer", "stat_users": "Gesamt Benutzer",
"stat_anon_users": "Anonyme Benutzer",
"most_favorited": "Meiste Favs", "most_favorited": "Meiste Favs",
"favs": "Favs", "favs": "Favs",
"top_xd": "Top xD-Score" "top_xd": "Top xD-Score"
+1
View File
@@ -518,6 +518,7 @@
"stat_favs": "Total Favorites", "stat_favs": "Total Favorites",
"stat_disk_usage": "Total File Size", "stat_disk_usage": "Total File Size",
"stat_users": "Total Users", "stat_users": "Total Users",
"stat_anon_users": "Anonymous Users",
"most_favorited": "Most Favorited", "most_favorited": "Most Favorited",
"favs": "favs", "favs": "favs",
"top_xd": "Top xD Scores" "top_xd": "Top xD Scores"
+1
View File
@@ -511,6 +511,7 @@
"stat_favs": "Totaal aantal favorieten", "stat_favs": "Totaal aantal favorieten",
"stat_disk_usage": "Totale Bestandsgrootte", "stat_disk_usage": "Totale Bestandsgrootte",
"stat_users": "Totaal Gebruikers", "stat_users": "Totaal Gebruikers",
"stat_anon_users": "Anonieme gebruikers",
"most_favorited": "Meest Gefavoriet", "most_favorited": "Meest Gefavoriet",
"favs": "favorieten", "favs": "favorieten",
"top_xd": "Top xD-scores" "top_xd": "Top xD-scores"
+1
View File
@@ -512,6 +512,7 @@
"stat_favs": "Gesamtanzahl Favoriten", "stat_favs": "Gesamtanzahl Favoriten",
"stat_disk_usage": "Dateigröße Gesamt", "stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer", "stat_users": "Gesamt Benutzer",
"stat_anon_users": "Anonymer Alkoholiker",
"most_favorited": "Am häufigsten favorisiert", "most_favorited": "Am häufigsten favorisiert",
"favs": "Favoriten", "favs": "Favoriten",
"top_xd": "Beste xD-Punktestände" "top_xd": "Beste xD-Punktestände"
+92 -12
View File
@@ -2328,7 +2328,9 @@ const f0cklib = {
mime, mime,
exclude_ids, exclude_ids,
session_tags = '', session_tags = '',
session_creators = '' session_creators = '',
continuation = false,
prefer_personalized = null
} = {}) => { } = {}) => {
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null; const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const modequery = computeBaseMode(mode, ratingsArr, session); 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; let personalizedTarget;
if (maxLimit === 1) { if (maxLimit === 1) {
// For single-card replacement: 65% chance personalized, 35% chance discovery if (prefer_personalized === true) {
personalizedTarget = Math.random() < 0.65 ? 1 : 0; 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 { } 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); const targetTagIds = targetTagEntries.map(([tid]) => tid);
@@ -2537,8 +2556,9 @@ const f0cklib = {
let randomItems = []; let randomItems = [];
if (neededRandom > 0) { if (neededRandom > 0) {
const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)]; const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)];
// Fetch extra buffer of random items to ensure ample spacing
randomItems = await f0cklib.getRandomRecommendations({ randomItems = await f0cklib.getRandomRecommendations({
limit: neededRandom, limit: Math.max(neededRandom + 3, maxLimit),
mode, mode,
ratings, ratings,
session, session,
@@ -2550,14 +2570,74 @@ const f0cklib = {
}); });
} }
// 4. Combine & Interweave with Fisher-Yates Shuffle // 4. Combine & Interweave
const combined = [...personalizedItems, ...randomItems]; if (maxLimit === 1) {
for (let i = combined.length - 1; i > 0; i--) { return personalizedItems.length > 0 ? personalizedItems.slice(0, 1) : randomItems.slice(0, 1);
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
} }
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 ({ getTagFeedItems: async ({
+209
View File
@@ -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\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
const userId = +req.params.userId; const userId = +req.params.userId;
const user = await db`select "user", login from "user" where id = ${userId} limit 1`; 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} 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 // Log it in audit
await audit.log(req.session.id, 'ban_user', 'user', +user_id, { reason, duration, target_user: targetUser[0].user }); 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} 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 // Log it in audit
await audit.log(req.session.id, 'unban_user', 'user', +user_id); await audit.log(req.session.id, 'unban_user', 'user', +user_id);
+130 -3
View File
@@ -1,6 +1,7 @@
import db from '../../sql.mjs'; import db from '../../sql.mjs';
import lib from '../../lib.mjs'; import lib from '../../lib.mjs';
import cfg from '../../config.mjs'; import cfg from '../../config.mjs';
import security from '../../security.mjs';
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs'; import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs';
import { getEnableAnonymousAccess } from '../../settings.mjs'; import { getEnableAnonymousAccess } from '../../settings.mjs';
@@ -17,6 +18,19 @@ export default router => {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403); 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 body = req.post || req.body || {};
const pubkey = (body.pubkey || '').trim(); const pubkey = (body.pubkey || '').trim();
const timestamp = parseInt(body.timestamp, 10); const timestamp = parseInt(body.timestamp, 10);
@@ -39,8 +53,119 @@ export default router => {
} }
const parsed = parseOpenSshPubkey(pubkey); const parsed = parseOpenSshPubkey(pubkey);
const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint); const hwFingerprint = (body.hw_fingerprint || '').trim() || null;
const { session, csrf_token } = await createAnonSession(userId, req);
// 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')}`); 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, user_id: userId,
fingerprint: parsed.fingerprint, fingerprint: parsed.fingerprint,
short_fingerprint: parsed.shortFingerprint, short_fingerprint: parsed.shortFingerprint,
hw_fingerprint: hwFingerprint,
csrf_token: csrf_token csrf_token: csrf_token
}); });
} catch (err) { } catch (err) {
@@ -73,7 +199,7 @@ export default router => {
} }
const rows = await db` const rows = await db`
SELECT pubkey, fingerprint, created_at, last_seen SELECT pubkey, fingerprint, hw_fingerprint, created_at, last_seen
FROM anon_identities FROM anon_identities
WHERE user_id = ${req.session.id} WHERE user_id = ${req.session.id}
LIMIT 1 LIMIT 1
@@ -87,6 +213,7 @@ export default router => {
user_id: req.session.id, user_id: req.session.id,
fingerprint: fp, fingerprint: fp,
short_fingerprint: fp.slice(7, 15), short_fingerprint: fp.slice(7, 15),
hw_fingerprint: rows[0].hw_fingerprint,
pubkey: rows[0].pubkey, pubkey: rows[0].pubkey,
csrf_token: req.session.csrf_token csrf_token: req.session.csrf_token
}); });
+21 -1
View File
@@ -12,6 +12,7 @@ import { parseMultipart, collectBody } from '../../multipart.mjs';
import { purgeExpiredUploads } from '../../lib_delete.mjs'; import { purgeExpiredUploads } from '../../lib_delete.mjs';
import { calculateExpiresAt } from './upload.mjs'; import { calculateExpiresAt } from './upload.mjs';
import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs'; import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs';
import { logAnonActivity } from '../../anon_auth.mjs';
const allowedMimes = ["audio", "image", "video", "%"]; const allowedMimes = ["audio", "image", "video", "%"];
const getGlobalfilter = () => { const getGlobalfilter = () => {
@@ -730,6 +731,13 @@ export default router => {
const sessionTags = req.url.qs?.session_tags || ''; const sessionTags = req.url.qs?.session_tags || '';
const sessionCreators = req.url.qs?.session_creators || ''; 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({ const items = await f0cklib.getPersonalizedRecommendations({
limit, limit,
@@ -742,7 +750,9 @@ export default router => {
mime, mime,
exclude_ids: excludeIds, exclude_ids: excludeIds,
session_tags: sessionTags, session_tags: sessionTags,
session_creators: sessionCreators session_creators: sessionCreators,
continuation,
prefer_personalized: preferPersonalized
}); });
res.json({ res.json({
@@ -1381,6 +1391,9 @@ export default router => {
and item_id = ${+postid} and item_id = ${+postid}
`; `;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: -5.0 }).catch(() => {}); 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 { } else {
// add fav — ON CONFLICT DO NOTHING guards against rapid double-taps // add fav — ON CONFLICT DO NOTHING guards against rapid double-taps
await db` await db`
@@ -1391,6 +1404,9 @@ export default router => {
on conflict do nothing on conflict do nothing
`; `;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: 5.0 }).catch(() => {}); 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` 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 }); return res.json({ success: true, imported: count });
} catch (err) { } catch (err) {
console.error('[FAVORITES_IMPORT_ERROR]', err); console.error('[FAVORITES_IMPORT_ERROR]', err);
+22
View File
@@ -5,6 +5,7 @@ import queue from "../../queue.mjs";
import cfg from "../../config.mjs"; import cfg from "../../config.mjs";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { logAnonActivity } from "../../anon_auth.mjs";
export default router => { export default router => {
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => { router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
@@ -86,6 +87,13 @@ export default router => {
} }
const freshTags = await lib.getTags(postid); 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`); 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 })); 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' }; const { label, cls } = labels[nextTagId] || { label: 'SFW', cls: 'sfw' };
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }).catch(() => {}); 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); const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {}); await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {});
@@ -281,6 +296,13 @@ export default router => {
if (reply) { if (reply) {
const reason = req.post.reason || req.url.qs.reason || 'No reason provided'; 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 }); 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); const freshTags = await lib.getTags(postid);
+32 -3
View File
@@ -1,8 +1,37 @@
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import security from "../security.mjs";
export default (router, tpl) => { export default (router, tpl) => {
router.get(/^\/banned\/?$/, async (req, res) => { 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, { return res.writeHead(302, {
"Location": "/" "Location": "/"
}).end(); }).end();
@@ -11,8 +40,8 @@ export default (router, tpl) => {
res.reply({ res.reply({
body: tpl.render("banned", { body: tpl.render("banned", {
session: req.session, session: req.session,
reason: req.session.ban_reason, reason: reason,
expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent', expires: expires ? new Date(expires).toLocaleString() : 'Permanent',
ban_video: cfg.websrv.ban_video, ban_video: cfg.websrv.ban_video,
hideNavbar: true hideNavbar: true
}, req) }, req)
+28 -2
View File
@@ -7,7 +7,7 @@ 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 { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { getEnableAnonymousAccess } from "../settings.mjs"; import { getEnableAnonymousAccess } from "../settings.mjs";
export default (router, tpl) => { export default (router, tpl) => {
@@ -457,11 +457,13 @@ export default (router, tpl) => {
} }
} }
const auditIp = resolveAuditIP(req);
const insertData = { const insertData = {
item_id, item_id,
user_id: req.session.id, user_id: req.session.id,
parent_id: parent_id || null, parent_id: parent_id || null,
content: content || '' content: content || '',
ip: auditIp
}; };
if (video_time !== null) insertData.video_time = video_time; if (video_time !== null) insertData.video_time = video_time;
@@ -472,6 +474,14 @@ export default (router, tpl) => {
const commentId = parseInt(newComment[0].id, 10); 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) // Link uploaded files to this comment (if any)
let activityFiles = []; let activityFiles = [];
const fileIdsRaw = body.file_ids || ''; const fileIdsRaw = body.file_ids || '';
@@ -785,6 +795,14 @@ export default (router, tpl) => {
old_content: comment[0].content 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 // Handle attachments cleanup
const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`; const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`;
for (const f of files) { 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 // Return updated tally
const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`; 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; const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true;
+15
View File
@@ -59,6 +59,11 @@ export default (router, tpl) => {
from "tags_assign" from "tags_assign"
left join "user" on "user".id = "tags_assign".user_id left join "user" on "user".id = "tags_assign".user_id
left join "user_options" on "user_options".user_id = "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 group by "user".user, "user_options".avatar, "user_options".avatar_file, "user".admin, "user_options".display_name
order by count desc order by count desc
`; `;
@@ -81,6 +86,15 @@ export default (router, tpl) => {
const totalUsers = +(await db` const totalUsers = +(await db`
select count(*) as total select count(*) as total
from "user" 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; `)[0].total;
const hoster = await db` const hoster = await db`
@@ -141,6 +155,7 @@ export default (router, tpl) => {
totalComments, totalComments,
totalFavs, totalFavs,
totalUsers, totalUsers,
totalAnonUsers,
enable_nsfl: config.enable_nsfl, enable_nsfl: config.enable_nsfl,
diskSize: cachedDiskSize, diskSize: cachedDiskSize,
tmp: null, tmp: null,
+205
View File
@@ -190,4 +190,209 @@ export default new class {
on conflict (user_id, ip) do update set last_seen = now() on conflict (user_id, ip) do update set last_seen = now()
`.catch(err => console.error(`[SECURITY] Failed to log user IP:`, err)); `.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 };
}
}; };
+43 -2
View File
@@ -867,14 +867,55 @@ process.on('uncaughtException', err => {
} }
// 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 (Session)
if (req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) { if (req.session && req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) {
const now = new Date(); const now = new Date();
if (req.session.ban_expires && new Date(req.session.ban_expires) < now) { if (req.session.ban_expires && new Date(req.session.ban_expires) < now) {
// Ban expired, lift it // Ban expired, lift it
await db`update "user" set banned = false, ban_reason = null, ban_expires = null where id = ${+req.session.id}`; await db`update "user" set banned = false, ban_reason = null, ban_expires = null where id = ${+req.session.id}`;
req.session.banned = false; req.session.banned = false;
} else { } 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, { res.writeHead(307, {
"Location": "/banned" "Location": "/banned"
}).end(); }).end();
+25 -4
View File
@@ -11,6 +11,7 @@ import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs"; import f0cklib from "./inc/routeinc/f0cklib.mjs";
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs"; import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
import { addPrivateItem } from "./inc/private_items.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. // 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 ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
const filename = `yt:${videoId}`; const filename = `yt:${videoId}`;
const auditIp = resolveAuditIP(req);
const [{ id: itemid }] = await db` const [{ id: itemid }] = await db`
insert into items ${db({ insert into items ${db({
src: ytUrl, src: ytUrl,
@@ -361,11 +363,20 @@ export const handleUpload = async (req, res, self) => {
title: title, title: title,
visibility: targetVisibility, visibility: targetVisibility,
slug: itemSlug, slug: itemSlug,
expires_at: targetExpiresAt expires_at: targetExpiresAt,
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')} 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 RETURNING id
`; `;
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'upload',
targetId: itemid,
details: { type: 'youtube', videoId, slug: itemSlug }
});
}
if (targetVisibility === 2) { if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user); addPrivateItem(itemid, filename, req.session.user);
} }
@@ -741,6 +752,7 @@ export const handleUpload = async (req, res, self) => {
} catch (e) {} } catch (e) {}
} }
const auditIp = resolveAuditIP(req);
await db` await db`
insert into items ${db({ insert into items ${db({
src: inputUrl || '', src: inputUrl || '',
@@ -761,12 +773,21 @@ export const handleUpload = async (req, res, self) => {
height: itemHeight, height: itemHeight,
visibility: targetVisibility, visibility: targetVisibility,
slug: itemSlug, slug: itemSlug,
expires_at: targetExpiresAt 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')} 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); 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) { if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user); addPrivateItem(itemid, filename, req.session.user);
} }
+1
View File
@@ -16,6 +16,7 @@
<li><a href="/admin/sessions">Sessions</a></li> <li><a href="/admin/sessions">Sessions</a></li>
<li><a href="/admin/tokens">Invite Tokens</a></li> <li><a href="/admin/tokens">Invite Tokens</a></li>
<li><a href="/admin/users">User Manager</a></li> <li><a href="/admin/users">User Manager</a></li>
<li><a href="/admin/bans">Ban & Fingerprint Manager</a></li>
<li><a href="/admin/emojis">Emoji Manager</a></li> <li><a href="/admin/emojis">Emoji Manager</a></li>
<li><a href="/admin/memes">Meme Manager</a></li> <li><a href="/admin/memes">Meme Manager</a></li>
<li><a href="/admin/koepfe">Köpfe Manager</a></li> <li><a href="/admin/koepfe">Köpfe Manager</a></li>
+788
View File
@@ -0,0 +1,788 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container" style="max-width: 1200px; margin: 0 auto; padding: 20px 15px;">
<div style="margin-bottom: 25px;">
<a href="/admin" style="color: var(--accent); text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 12px;">
<i class="fa fa-arrow-left"></i> Back to Admin Dashboard
</a>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div>
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px; display: flex; align-items: center; gap: 10px;">
<i class="fa-solid fa-shield-halved" style="color: var(--accent);"></i> Ban & Fingerprint Manager
</h2>
<p style="color: #aaa; margin: 6px 0 0 0; font-size: 0.95rem;">
Inspect, track, and manage OpenSSH key fingerprints, IP bans, anonymous identities, and audit activity.
</p>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button onclick="openBanModal('fingerprint')" class="btn-upload" style="width: auto; padding: 8px 16px; background: #e74c3c; border-color: #c0392b; font-size: 0.85rem; cursor: pointer; border-radius: 4px; color: #fff;">
<i class="fa-solid fa-ban"></i> Ban Fingerprint
</button>
<button onclick="openBanModal('hardware')" class="btn-upload" style="width: auto; padding: 8px 16px; background: #8e44ad; border-color: #7d3c98; font-size: 0.85rem; cursor: pointer; border-radius: 4px; color: #fff;">
<i class="fa-solid fa-microchip"></i> Ban Hardware ID
</button>
<button onclick="openBanModal('ip')" class="btn-upload" style="width: auto; padding: 8px 16px; background: #d35400; border-color: #ba4a00; font-size: 0.85rem; cursor: pointer; border-radius: 4px; color: #fff;">
<i class="fa-solid fa-network-wired"></i> Ban IP
</button>
</div>
</div>
</div>
<!-- Quick Stats Cards -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 25px;">
<div style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 16px; text-align: center;">
<div style="font-size: 0.8rem; color: #888; text-transform: uppercase; letter-spacing: 0.5px;">Banned Fingerprints</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #ff6b6b; margin-top: 4px;">{{ bannedFingerprints ? bannedFingerprints.length : 0 }}</div>
</div>
<div style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 16px; text-align: center;">
<div style="font-size: 0.8rem; color: #888; text-transform: uppercase; letter-spacing: 0.5px;">Banned Hardware IDs</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #cc5de8; margin-top: 4px;">{{ bannedHardware ? bannedHardware.length : 0 }}</div>
</div>
<div style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 16px; text-align: center;">
<div style="font-size: 0.8rem; color: #888; text-transform: uppercase; letter-spacing: 0.5px;">Banned IP Addresses</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #ffa94d; margin-top: 4px;">{{ bannedIps ? bannedIps.length : 0 }}</div>
</div>
<div style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 16px; text-align: center;">
<div style="font-size: 0.8rem; color: #888; text-transform: uppercase; letter-spacing: 0.5px;">Known Anonymous Keys</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #69db7c; margin-top: 4px;">{{ anonIdentities ? anonIdentities.length : 0 }}</div>
</div>
<div style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 16px; text-align: center;">
<div style="font-size: 0.8rem; color: #888; text-transform: uppercase; letter-spacing: 0.5px;">Recent Logged Actions</div>
<div style="font-size: 1.8rem; font-weight: 800; color: #74c0fc; margin-top: 4px;">{{ recentActivity ? recentActivity.length : 0 }}</div>
</div>
</div>
<!-- Navigation Tabs -->
<div style="display: flex; gap: 8px; border-bottom: 1px solid rgba(255,255,255,0.12); margin-bottom: 20px; overflow-x: auto;">
<button onclick="switchTab('fingerprints')" id="tab-btn-fingerprints" class="admin-tab-btn active-tab">
<i class="fa-solid fa-key"></i> Banned Fingerprints ({{ bannedFingerprints ? bannedFingerprints.length : 0 }})
</button>
<button onclick="switchTab('hardware')" id="tab-btn-hardware" class="admin-tab-btn">
<i class="fa-solid fa-microchip"></i> Banned Hardware ({{ bannedHardware ? bannedHardware.length : 0 }})
</button>
<button onclick="switchTab('ips')" id="tab-btn-ips" class="admin-tab-btn">
<i class="fa-solid fa-network-wired"></i> Banned IPs ({{ bannedIps ? bannedIps.length : 0 }})
</button>
<button onclick="switchTab('identities')" id="tab-btn-identities" class="admin-tab-btn">
<i class="fa-solid fa-user-secret"></i> Anonymous Identities ({{ anonIdentities ? anonIdentities.length : 0 }})
</button>
<button onclick="switchTab('activity')" id="tab-btn-activity" class="admin-tab-btn">
<i class="fa-solid fa-list-check"></i> Activity Audit ({{ recentActivity ? recentActivity.length : 0 }})
</button>
</div>
<!-- TAB 1: BANNED FINGERPRINTS -->
<div id="tab-pane-fingerprints" class="tab-pane active-pane">
<div class="upload-form" style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 6px;">
<table class="responsive-table clean-table" style="width: 100%;">
<thead>
<tr>
<th>Fingerprint</th>
<th>Linked Account</th>
<th>Reason</th>
<th>Banned By</th>
<th>Date</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@if(bannedFingerprints && bannedFingerprints.length > 0)
@each(bannedFingerprints as fp)
<tr>
<td data-label="Fingerprint">
<div style="display: flex; align-items: center; gap: 6px;">
<code class="clickable-code" onclick="copyText('{{ fp.fingerprint }}')" title="Click to copy full fingerprint">
{{ fp.fingerprint }}
</code>
<button onclick="copyText('{{ fp.fingerprint }}')" class="btn-icon" title="Copy fingerprint">
<i class="fa-regular fa-copy"></i>
</button>
</div>
</td>
<td data-label="Linked Account">
@if(fp.anon_login)
<a href="/user/{{ fp.anon_login }}" target="_blank" style="color: var(--accent); font-weight: bold; text-decoration: none;">
<i class="fa-solid fa-user-secret"></i> {!! fp.anon_user || fp.anon_login !!}
</a>
@else
<span style="color: #888;"></span>
@endif
</td>
<td data-label="Reason">
<span style="color: #ff8787; font-weight: 500;">{{ fp.reason || 'No reason specified' }}</span>
</td>
<td data-label="Banned By">
{{ fp.banned_by_user || 'System / Admin' }}
</td>
<td data-label="Date">
{{ fp.created_at ? new Date(fp.created_at).toLocaleString() : '—' }}
</td>
<td data-label="Expires">
@if(fp.expires_at)
<span style="color: #fcc419;">{{ new Date(fp.expires_at).toLocaleString() }}</span>
@else
<span style="color: #ff6b6b; font-weight: bold;">Permanent</span>
@endif
</td>
<td data-label="Action">
<button onclick="unbanFingerprint('{{ fp.fingerprint }}')" class="btn-remove" style="padding: 4px 10px; font-size: 0.8rem; background: #27ae60; border-color: #2ecc71; color: #fff; cursor: pointer; border-radius: 4px;">
<i class="fa-solid fa-unlock"></i> Unban
</button>
</td>
</tr>
@endeach
@else
<tr>
<td colspan="7" style="text-align: center; padding: 30px; color: #888;">
<i class="fa-solid fa-circle-check" style="color: #51cf66; margin-right: 6px;"></i> No banned fingerprints.
</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
<!-- TAB: BANNED HARDWARE -->
<div id="tab-pane-hardware" class="tab-pane" style="display: none;">
<div class="upload-form" style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 6px;">
<table class="responsive-table clean-table" style="width: 100%;">
<thead>
<tr>
<th>Hardware Fingerprint</th>
<th>Reason</th>
<th>Banned By</th>
<th>Date</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@if(bannedHardware && bannedHardware.length > 0)
@each(bannedHardware as hw)
<tr>
<td data-label="Hardware ID">
<div style="display: flex; align-items: center; gap: 6px;">
<code class="clickable-code" onclick="copyText('{{ hw.hw_fingerprint }}')" title="Click to copy hardware ID">
{{ hw.hw_fingerprint.substring(0, 24) }}...
</code>
<button onclick="copyText('{{ hw.hw_fingerprint }}')" class="btn-icon" title="Copy Hardware ID">
<i class="fa-regular fa-copy"></i>
</button>
</div>
</td>
<td data-label="Reason">
<span style="color: #ff8787;">{{ hw.reason || 'Banned' }}</span>
</td>
<td data-label="Banned By">
@if(hw.banned_by_user)
<span style="color: #aaa;">{{ hw.banned_by_user }}</span>
@else
<span style="color: #777;">System</span>
@endif
</td>
<td data-label="Date">
{{ hw.created_at ? new Date(hw.created_at).toLocaleDateString() : '—' }}
</td>
<td data-label="Expires">
@if(hw.expires_at)
<span style="color: #fcc419;">{{ new Date(hw.expires_at).toLocaleString() }}</span>
@else
<span style="color: #ff6b6b; font-weight: bold;">Permanent</span>
@endif
</td>
<td data-label="Action">
<button onclick="unbanHardware('{{ hw.hw_fingerprint }}')" class="btn-remove" style="padding: 4px 10px; font-size: 0.8rem; background: #27ae60; border-color: #2ecc71; color: #fff; cursor: pointer; border-radius: 4px;">
<i class="fa-solid fa-unlock"></i> Unban
</button>
</td>
</tr>
@endeach
@else
<tr>
<td colspan="6" style="text-align: center; padding: 30px; color: #888;">
<i class="fa-solid fa-circle-check" style="color: #51cf66; margin-right: 6px;"></i> No banned hardware fingerprints.
</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
<!-- TAB 2: BANNED IPS -->
<div id="tab-pane-ips" class="tab-pane" style="display: none;">
<div class="upload-form" style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 6px;">
<table class="responsive-table clean-table" style="width: 100%;">
<thead>
<tr>
<th>IP / Hash</th>
<th>Reason</th>
<th>Banned By</th>
<th>Date</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@if(bannedIps && bannedIps.length > 0)
@each(bannedIps as item)
<tr>
<td data-label="IP / Hash">
<div style="display: flex; align-items: center; gap: 6px;">
<code class="clickable-code" onclick="copyText('{{ item.ip }}')" title="Click to copy IP">
{{ item.ip }}
</code>
<button onclick="copyText('{{ item.ip }}')" class="btn-icon" title="Copy IP">
<i class="fa-regular fa-copy"></i>
</button>
</div>
@if(item.ip_hash && item.ip_hash !== item.ip)
<div style="font-size: 0.75rem; color: #777; margin-top: 3px;">
Hash: {{ item.ip_hash.substring(0, 16) }}...
</div>
@endif
</td>
<td data-label="Reason">
<span style="color: #ff8787; font-weight: 500;">{{ item.reason || 'No reason specified' }}</span>
</td>
<td data-label="Banned By">
{{ item.banned_by_user || 'System / Admin' }}
</td>
<td data-label="Date">
{{ item.created_at ? new Date(item.created_at).toLocaleString() : '—' }}
</td>
<td data-label="Expires">
@if(item.expires_at)
<span style="color: #fcc419;">{{ new Date(item.expires_at).toLocaleString() }}</span>
@else
<span style="color: #ff6b6b; font-weight: bold;">Permanent</span>
@endif
</td>
<td data-label="Action">
<button onclick="unbanIp('{{ item.ip }}')" class="btn-remove" style="padding: 4px 10px; font-size: 0.8rem; background: #27ae60; border-color: #2ecc71; color: #fff; cursor: pointer; border-radius: 4px;">
<i class="fa-solid fa-unlock"></i> Unban
</button>
</td>
</tr>
@endeach
@else
<tr>
<td colspan="6" style="text-align: center; padding: 30px; color: #888;">
<i class="fa-solid fa-circle-check" style="color: #51cf66; margin-right: 6px;"></i> No banned IP addresses.
</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
<!-- TAB 3: ANONYMOUS IDENTITIES -->
<div id="tab-pane-identities" class="tab-pane" style="display: none;">
<div class="upload-form" style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 6px;">
<table class="responsive-table clean-table" style="width: 100%;">
<thead>
<tr>
<th>Account</th>
<th>Public Key Fingerprint</th>
<th>Hardware ID</th>
<th>Created / IP</th>
<th>Last Seen / IP</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@if(anonIdentities && anonIdentities.length > 0)
@each(anonIdentities as idn)
<tr>
<td data-label="Account">
@if(idn.login)
<a href="/user/{{ idn.login }}" target="_blank" style="color: var(--accent); font-weight: bold; text-decoration: none; display: inline-flex; align-items: center; gap: 5px;">
<i class="fa-solid fa-user-secret"></i> {!! idn.user || idn.login !!}
</a>
<div style="font-size: 0.75rem; color: #888;">UID: {{ idn.user_id }}</div>
@else
<span style="color: #888;">UID: {{ idn.user_id }} (Deleted)</span>
@endif
</td>
<td data-label="Fingerprint">
<div style="display: flex; align-items: center; gap: 6px;">
<code class="clickable-code" onclick="copyText('{{ idn.fingerprint }}')" title="Click to copy full fingerprint">
{{ idn.fingerprint.substring(0, 22) }}...
</code>
<button onclick="copyText('{{ idn.fingerprint }}')" class="btn-icon" title="Copy fingerprint">
<i class="fa-regular fa-copy"></i>
</button>
</div>
</td>
<td data-label="Hardware ID">
@if(idn.hw_fingerprint)
<div style="display: flex; align-items: center; gap: 6px;">
<code class="clickable-code" onclick="copyText('{{ idn.hw_fingerprint }}')" title="Click to copy full hardware ID">
{{ idn.hw_fingerprint.substring(0, 18) }}...
</code>
<button onclick="copyText('{{ idn.hw_fingerprint }}')" class="btn-icon" title="Copy hardware ID">
<i class="fa-regular fa-copy"></i>
</button>
@if(idn.is_hw_banned)
<span class="badge" style="background: #e74c3c; color: #fff; padding: 2px 5px; border-radius: 3px; font-size: 0.65rem;">HW BANNED</span>
@else
<button onclick="openBanModal('hardware', '{{ idn.hw_fingerprint }}', {{ idn.user_id }})" class="btn-icon" title="Ban Hardware ID" style="color: #e74c3c; margin-left: 2px;">
<i class="fa-solid fa-ban"></i>
</button>
@endif
</div>
@else
<span style="color: #666; font-size: 0.8rem;"></span>
@endif
</td>
<td data-label="Created / IP">
<div>{{ idn.created_at ? new Date(idn.created_at).toLocaleDateString() : '—' }}</div>
@if(idn.created_ip)
<div style="font-size: 0.75rem; color: #888; font-family: monospace;">{{ idn.created_ip.substring(0, 16) }}...</div>
@endif
</td>
<td data-label="Last Seen / IP">
<div>{{ idn.last_seen ? new Date(idn.last_seen).toLocaleDateString() : '—' }}</div>
@if(idn.last_ip)
<div style="font-size: 0.75rem; color: #888; font-family: monospace;">{{ idn.last_ip.substring(0, 16) }}...</div>
@endif
</td>
<td data-label="Status">
@if(idn.is_banned || idn.is_fp_banned || idn.is_hw_banned)
<span class="badge" style="background: #e74c3c; color: #fff; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem;">BANNED</span>
@else
<span class="badge" style="background: #27ae60; color: #fff; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem;">ACTIVE</span>
@endif
</td>
<td data-label="Actions">
@if(idn.is_banned || idn.is_fp_banned)
<button onclick="unbanFingerprint('{{ idn.fingerprint }}')" class="btn-remove" style="padding: 4px 10px; font-size: 0.8rem; background: #27ae60; border-color: #2ecc71; color: #fff; cursor: pointer; border-radius: 4px;">
<i class="fa-solid fa-unlock"></i> Unban Key
</button>
@else
<button onclick="promptBanIdentity({{ idn.user_id }}, '{{ idn.fingerprint }}')" class="btn-remove" style="padding: 4px 10px; font-size: 0.8rem; background: #e74c3c; border-color: #c0392b; color: #fff; cursor: pointer; border-radius: 4px;">
<i class="fa-solid fa-ban"></i> Ban Key
</button>
@endif
</td>
</tr>
@endeach
@else
<tr>
<td colspan="7" style="text-align: center; padding: 30px; color: #888;">
No anonymous identities found in database.
</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
<!-- TAB 4: ACTIVITY AUDIT LOG -->
<div id="tab-pane-activity" class="tab-pane" style="display: none;">
<div class="upload-form" style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 6px;">
<table class="responsive-table clean-table" style="width: 100%;">
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Action</th>
<th>Target ID</th>
<th>Details</th>
<th>IP</th>
<th>Hardware ID</th>
</tr>
</thead>
<tbody>
@if(recentActivity && recentActivity.length > 0)
@each(recentActivity as act)
<tr>
<td data-label="Time" style="white-space: nowrap; font-size: 0.85rem;">
{{ act.created_at ? new Date(act.created_at).toLocaleString() : '—' }}
</td>
<td data-label="User">
@if(act.login)
<a href="/user/{{ act.login }}" target="_blank" style="color: var(--accent); text-decoration: none;">
{!! act.user || act.login !!}
</a>
@else
<span style="color: #888;">UID: {{ act.user_id }}</span>
@endif
</td>
<td data-label="Action">
<span class="action-badge action-{{ act.action }}">
{{ act.action }}
</span>
</td>
<td data-label="Target ID">
@if(act.target_id)
@if(act.action === 'comment' || act.action === 'upload')
<a href="/{{ act.target_id }}" target="_blank" style="color: var(--accent); text-decoration: underline;">
#{{ act.target_id }}
</a>
@else
#{{ act.target_id }}
@endif
@else
<span style="color: #777;"></span>
@endif
</td>
<td data-label="Details" style="font-size: 0.8rem; font-family: monospace; max-width: 300px; word-break: break-all;">
@if(act.details)
{{ JSON.stringify(act.details) }}
@else
<span style="color: #777;"></span>
@endif
</td>
<td data-label="IP" style="font-family: monospace; font-size: 0.8rem;">
{{ act.ip }}
</td>
<td data-label="Hardware ID" style="font-family: monospace; font-size: 0.8rem;">
@if(act.hw_fingerprint)
<div style="display: flex; align-items: center; gap: 6px;">
<code class="clickable-code" onclick="copyText('{{ act.hw_fingerprint }}')" title="Click to copy Hardware ID">
{{ act.hw_fingerprint.substring(0, 16) }}...
</code>
<button onclick="copyText('{{ act.hw_fingerprint }}')" class="btn-icon" title="Copy Hardware ID">
<i class="fa-regular fa-copy"></i>
</button>
</div>
@else
<span style="color: #777;"></span>
@endif
</td>
</tr>
@endeach
@else
<tr>
<td colspan="7" style="text-align: center; padding: 30px; color: #888;">
No anonymous activity recorded yet.
</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: Ban Fingerprint or IP -->
<div id="ban-modal" class="modal-overlay" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 10000; align-items: center; justify-content: center;">
<div style="background: #1e1e1e; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; width: 92%; max-width: 500px; padding: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.8);">
<h3 id="modal-title" style="margin-top: 0; margin-bottom: 15px; color: #fff; display: flex; align-items: center; gap: 8px;">
<i class="fa-solid fa-ban" style="color: #e74c3c;"></i> Ban Target
</h3>
<form id="ban-form" onsubmit="submitBan(event)">
<input type="hidden" id="ban-target-type" value="fingerprint">
<input type="hidden" id="ban-target-user-id" value="">
<div style="margin-bottom: 15px;">
<label id="target-value-label" style="display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 5px;">OpenSSH Fingerprint</label>
<input type="text" id="ban-target-value" required style="width: 100%; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; padding: 8px 10px; color: #fff; font-family: monospace; font-size: 0.9rem; box-sizing: border-box;" placeholder="SHA256:...">
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 5px;">Ban Reason (Displayed to User)</label>
<input type="text" id="ban-reason" required value="Violation of community guidelines" style="width: 100%; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; padding: 8px 10px; color: #fff; font-size: 0.9rem; box-sizing: border-box;">
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 5px;">Duration</label>
<select id="ban-duration" style="width: 100%; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; padding: 8px 10px; color: #fff; font-size: 0.9rem; box-sizing: border-box;">
<option value="1">1 Hour</option>
<option value="24">24 Hours</option>
<option value="168">7 Days</option>
<option value="720">30 Days</option>
<option value="permanent" selected>Permanent</option>
</select>
</div>
<div id="cascade-ips-group" style="margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="ban-cascade-ips" checked style="cursor: pointer;">
<label for="ban-cascade-ips" style="color: #ddd; font-size: 0.85rem; cursor: pointer;">
Also ban all associated IP addresses used by this identity
</label>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" onclick="closeBanModal()" style="background: transparent; border: 1px solid #555; color: #ccc; padding: 8px 16px; border-radius: 4px; cursor: pointer;">Cancel</button>
<button type="submit" style="background: #e74c3c; border: 1px solid #c0392b; color: #fff; padding: 8px 18px; border-radius: 4px; font-weight: bold; cursor: pointer;">Enforce Ban</button>
</div>
</form>
</div>
</div>
<style>
.admin-tab-btn {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: #888;
padding: 10px 16px;
font-size: 0.95rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.15s ease;
}
.admin-tab-btn:hover {
color: #fff;
}
.admin-tab-btn.active-tab {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: bold;
}
.clickable-code {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 6px;
border-radius: 3px;
color: #a5d8ff;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.clickable-code:hover {
background: rgba(255,255,255,0.12);
color: #d0ebff;
}
.btn-icon {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 0.85rem;
padding: 2px;
}
.btn-icon:hover {
color: var(--accent);
}
.action-badge {
display: inline-block;
padding: 2px 7px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.action-comment { background: rgba(51, 154, 240, 0.2); color: #74c0fc; border: 1px solid #339af0; }
.action-upload { background: rgba(81, 207, 102, 0.2); color: #51cf66; border: 1px solid #51cf66; }
.action-tag, .action-cycle_rating { background: rgba(252, 196, 25, 0.2); color: #fcc419; border: 1px solid #fcc419; }
.action-favorite, .action-favorites_import { background: rgba(255, 107, 107, 0.2); color: #ff6b6b; border: 1px solid #ff6b6b; }
.action-comment_delete, .action-delete_tag { background: rgba(224, 49, 49, 0.2); color: #ff8787; border: 1px solid #e03131; }
.action-handshake { background: rgba(132, 94, 247, 0.2); color: #b197fc; border: 1px solid #845ef7; }
</style>
<script>
function switchTab(tabId) {
document.querySelectorAll('.admin-tab-btn').forEach(btn => btn.classList.remove('active-tab'));
document.querySelectorAll('.tab-pane').forEach(pane => {
pane.style.display = 'none';
pane.classList.remove('active-pane');
});
const btn = document.getElementById('tab-btn-' + tabId);
const pane = document.getElementById('tab-pane-' + tabId);
if (btn) btn.classList.add('active-tab');
if (pane) {
pane.style.display = 'block';
pane.classList.add('active-pane');
}
}
function copyText(text) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
alert('Copied to clipboard: ' + text);
}).catch(() => {
prompt('Copy text:', text);
});
} else {
prompt('Copy text:', text);
}
}
function openBanModal(type, targetValue = '', userId = null) {
document.getElementById('ban-target-type').value = type;
document.getElementById('ban-target-user-id').value = userId || '';
document.getElementById('ban-target-value').value = targetValue;
const modalTitle = document.getElementById('modal-title');
const valueLabel = document.getElementById('target-value-label');
const cascadeGroup = document.getElementById('cascade-ips-group');
const targetInput = document.getElementById('ban-target-value');
if (type === 'fingerprint') {
modalTitle.innerHTML = '<i class="fa-solid fa-key" style="color: #e74c3c;"></i> Ban Key Fingerprint';
valueLabel.innerText = 'OpenSSH Key Fingerprint (e.g. SHA256:...)';
targetInput.placeholder = 'SHA256:...';
cascadeGroup.style.display = 'flex';
} else if (type === 'hardware') {
modalTitle.innerHTML = '<i class="fa-solid fa-microchip" style="color: #cc5de8;"></i> Ban Hardware ID';
valueLabel.innerText = 'Hardware Fingerprint (e.g. HW:...)';
targetInput.placeholder = 'HW:...';
cascadeGroup.style.display = 'flex';
} else {
modalTitle.innerHTML = '<i class="fa-solid fa-network-wired" style="color: #d35400;"></i> Ban IP Address';
valueLabel.innerText = 'IP Address (IPv4 or IPv6 or Hash)';
targetInput.placeholder = '198.51.100.42';
cascadeGroup.style.display = 'none';
}
document.getElementById('ban-modal').style.display = 'flex';
}
function closeBanModal() {
document.getElementById('ban-modal').style.display = 'none';
}
function promptBanIdentity(userId, fingerprint) {
openBanModal('fingerprint', fingerprint, userId);
}
function getCsrfToken() {
return (window.f0ckSession && window.f0ckSession.csrf_token) ||
document.querySelector('meta[name="csrf-token"]')?.content ||
'{{ csrf_token }}' ||
'{{ session ? session.csrf_token : "" }}' ||
'';
}
async function submitBan(e) {
e.preventDefault();
const type = document.getElementById('ban-target-type').value;
const value = document.getElementById('ban-target-value').value.trim();
const reason = document.getElementById('ban-reason').value.trim();
const duration = document.getElementById('ban-duration').value;
const cascadeIps = document.getElementById('ban-cascade-ips').checked;
const userId = document.getElementById('ban-target-user-id').value;
if (!value) return alert('Target value is required');
try {
const csrfToken = getCsrfToken();
let endpoint = type === 'fingerprint'
? '/api/v2/admin/bans/fingerprint/ban'
: (type === 'hardware' ? '/api/v2/admin/bans/hardware/ban' : '/api/v2/admin/bans/ip/ban');
const payload = type === 'fingerprint'
? { fingerprint: value, reason, duration, ban_ips: cascadeIps, user_id: userId || undefined, csrf_token: csrfToken }
: (type === 'hardware'
? { hw_fingerprint: value, reason, duration, ban_ips: cascadeIps, user_id: userId || undefined, csrf_token: csrfToken }
: { ip: value, reason, duration, csrf_token: csrfToken });
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
alert('Ban successfully applied!');
window.location.reload();
} else {
alert(data.msg || 'Failed to apply ban');
}
} catch (err) {
console.error(err);
alert('Network error while enforcing ban');
}
}
async function unbanFingerprint(fingerprint) {
if (!confirm('Are you sure you want to unban this key fingerprint: ' + fingerprint + '?')) return;
try {
const csrfToken = getCsrfToken();
const res = await fetch('/api/v2/admin/bans/fingerprint/unban', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ fingerprint, csrf_token: csrfToken })
});
const data = await res.json();
if (data.success) {
alert('Fingerprint successfully unbanned!');
window.location.reload();
} else {
alert(data.msg || 'Failed to unban fingerprint');
}
} catch (err) {
console.error(err);
alert('Network error while unbanning');
}
}
async function unbanHardware(hw_fingerprint) {
if (!confirm('Are you sure you want to unban this Hardware ID: ' + hw_fingerprint + '?')) return;
try {
const csrfToken = getCsrfToken();
const res = await fetch('/api/v2/admin/bans/hardware/unban', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ hw_fingerprint, csrf_token: csrfToken })
});
const data = await res.json();
if (data.success) {
alert('Hardware ID successfully unbanned!');
window.location.reload();
} else {
alert(data.msg || 'Failed to unban Hardware ID');
}
} catch (err) {
console.error(err);
alert('Network error while unbanning Hardware ID');
}
}
async function unbanIp(ip) {
if (!confirm('Are you sure you want to unban this IP: ' + ip + '?')) return;
try {
const csrfToken = getCsrfToken();
const res = await fetch('/api/v2/admin/bans/ip/unban', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ ip, csrf_token: csrfToken })
});
const data = await res.json();
if (data.success) {
alert('IP successfully unbanned!');
window.location.reload();
} else {
alert(data.msg || 'Failed to unban IP');
}
} catch (err) {
console.error(err);
alert('Network error while unbanning IP');
}
}
</script>
@include(snippets/footer)
+1
View File
@@ -74,6 +74,7 @@
<tr><td>{{ t('ranking.stat_total') }}</td><td>{{ stats.total }}</td></tr> <tr><td>{{ t('ranking.stat_total') }}</td><td>{{ stats.total }}</td></tr>
<tr><td>{{ t('ranking.stat_comments') }}</td><td>{{ totalComments }}</td></tr> <tr><td>{{ t('ranking.stat_comments') }}</td><td>{{ totalComments }}</td></tr>
<tr><td>{{ t('ranking.stat_users') }}</td><td>{{ totalUsers }}</td></tr> <tr><td>{{ t('ranking.stat_users') }}</td><td>{{ totalUsers }}</td></tr>
<tr><td>{{ t('ranking.stat_anon_users') }}</td><td>{{ totalAnonUsers }}</td></tr>
<tr><td>{{ t('ranking.stat_favs') }}</td><td>{{ totalFavs }}</td></tr> <tr><td>{{ t('ranking.stat_favs') }}</td><td>{{ totalFavs }}</td></tr>
<tr><td>{{ t('ranking.stat_tagged') }}</td><td>{{ stats.tagged }}</td></tr> <tr><td>{{ t('ranking.stat_tagged') }}</td><td>{{ stats.tagged }}</td></tr>
<tr><td>{{ t('ranking.stat_untagged') }}</td><td>{{ stats.untagged }}</td></tr> <tr><td>{{ t('ranking.stat_untagged') }}</td><td>{{ stats.untagged }}</td></tr>
+8 -8
View File
@@ -1,6 +1,6 @@
<div class="profile-page-wrapper" style="max-width: 800px; margin: 0 auto; width: 100%; padding: 20px 10px; box-sizing: border-box;"> <div class="profile-page-wrapper" style="max-width: 800px; margin: 0 auto; width: 100%; padding: 20px 10px; box-sizing: border-box;">
<div class="profile_head" id="live-profile-preview-box" data-banner-user="{{ user.user || '' }}" style="position: relative; @if(user.banner_file && user_banner_enabled) --author-banner: url('/a/{{ user.banner_file }}'); --author-banner-position: {{ user.banner_position === 'center' ? 'center top' : (user.banner_position || 'center top') }}; --author-banner-size: {{ user.banner_size || 'cover' }}; @endif"> <div class="profile_head" id="live-profile-preview-box" data-banner-user="{{ user.user || '' }}" style="position: relative; @if(user.banner_file && user_banner_enabled) --author-banner: url('/a/{{ user.banner_file }}'); --author-banner-position: {{ user.banner_position === 'center' ? 'center top' : (user.banner_position || 'center top') }}; --author-banner-size: {{ user.banner_size || 'cover' }}; @endif">
@if(session && session.id === user.user_id && user_banner_enabled) @if(session && session.id === user.user_id && !session.is_anon && user_banner_enabled)
<div id="inline-banner-edit-wrapper" style="display: none; position:absolute; top: 5px; right: 5px; z-index: 10;"> <div id="inline-banner-edit-wrapper" style="display: none; position:absolute; top: 5px; right: 5px; z-index: 10;">
<button id="inline-banner-edit-btn" onclick="const menu = document.getElementById('banner-edit-menu'); menu.style.display = menu.style.display === 'none' ? 'block' : 'none';" style="background: rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 4px 8px; color: #fff; cursor: pointer; font-size: 0.8em;" title="{{ t('profile.edit_banner', 'Edit Banner') }}"> <button id="inline-banner-edit-btn" onclick="const menu = document.getElementById('banner-edit-menu'); menu.style.display = menu.style.display === 'none' ? 'block' : 'none';" style="background: rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 4px 8px; color: #fff; cursor: pointer; font-size: 0.8em;" title="{{ t('profile.edit_banner', 'Edit Banner') }}">
<i class="fa-solid fa-panorama"></i> <i class="fa-solid fa-panorama"></i>
@@ -33,14 +33,14 @@
@else @else
<img src="/a/default.png" class="profile-avatar-img" /> <img src="/a/default.png" class="profile-avatar-img" />
@endif @endif
@if(session && session.id === user.user_id) @if(session && session.id === user.user_id && !session.is_anon)
<button id="inline-avatar-btn" class="avatar-edit-btn" title="{{ t('profile.edit_avatar', 'Edit Avatar') }}"> <button id="inline-avatar-btn" class="avatar-edit-btn" title="{{ t('profile.edit_avatar', 'Edit Avatar') }}">
<i class="fa-solid fa-pen" style="font-size: 0.8em;"></i> <i class="fa-solid fa-pen" style="font-size: 0.8em;"></i>
</button> </button>
<input type="file" id="inline-avatar-file" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;"> <input type="file" id="inline-avatar-file" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
@endif @endif
</div> </div>
@if(session && session.id === user.user_id) @if(session && session.id === user.user_id && !session.is_anon)
<button id="edit-profile-btn" class="btn btn-sm btn-outline-primary" onclick="const box = document.getElementById('live-profile-preview-box'); box.classList.toggle('edit-mode-active'); if(box.classList.contains('edit-mode-active')){ this.innerHTML='<i class=&quot;fa-solid fa-check&quot;></i>'; this.style.color='#51cf66'; this.style.borderColor='#51cf66'; } else { this.innerHTML='<i class=&quot;fa-solid fa-pen&quot;></i>'; this.style.color='var(--accent)'; this.style.borderColor='var(--accent)'; }" style="position: absolute; bottom: 5px; left: 5px; padding: 3px 8px; font-size: 0.85em; border: 1px solid var(--accent); color: var(--accent); background: rgba(0,0,0,0.8); cursor: pointer; border-radius: 4px; z-index: 20; box-shadow: 0 0 5px rgba(0,0,0,0.5);" title="{{ t('profile.edit_btn', 'Edit Profile') }}"><i class="fa-solid fa-pen"></i></button> <button id="edit-profile-btn" class="btn btn-sm btn-outline-primary" onclick="const box = document.getElementById('live-profile-preview-box'); box.classList.toggle('edit-mode-active'); if(box.classList.contains('edit-mode-active')){ this.innerHTML='<i class=&quot;fa-solid fa-check&quot;></i>'; this.style.color='#51cf66'; this.style.borderColor='#51cf66'; } else { this.innerHTML='<i class=&quot;fa-solid fa-pen&quot;></i>'; this.style.color='var(--accent)'; this.style.borderColor='var(--accent)'; }" style="position: absolute; bottom: 5px; left: 5px; padding: 3px 8px; font-size: 0.85em; border: 1px solid var(--accent); color: var(--accent); background: rgba(0,0,0,0.8); cursor: pointer; border-radius: 4px; z-index: 20; box-shadow: 0 0 5px rgba(0,0,0,0.5);" title="{{ t('profile.edit_btn', 'Edit Profile') }}"><i class="fa-solid fa-pen"></i></button>
@endif @endif
</div> </div>
@@ -49,13 +49,13 @@
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;"> <div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
<div style="flex: 1; min-width: 0; word-break: break-word; line-height: 1.2; align-self: flex-start; margin-top: 0; display: flex; align-items: center; gap: 5px;" id="profile-name-container"> <div style="flex: 1; min-width: 0; word-break: break-word; line-height: 1.2; align-self: flex-start; margin-top: 0; display: flex; align-items: center; gap: 5px;" id="profile-name-container">
<span id="profile-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)&#9889;&nbsp;@elseif(user.is_moderator)&#128737;&nbsp;@endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(session && user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif <span id="profile-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)&#9889;&nbsp;@elseif(user.is_moderator)&#128737;&nbsp;@endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(session && user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
@if(session && session.id === user.user_id) @if(session && session.id === user.user_id && !session.is_anon)
<button id="inline-name-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0;" title="Edit Name & Color"> <button id="inline-name-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0;" title="Edit Name & Color">
<i class="fa-solid fa-pen"></i> <i class="fa-solid fa-pen"></i>
</button> </button>
@endif @endif
</div> </div>
@if(session && session.id === user.user_id) @if(session && session.id === user.user_id && !session.is_anon)
<div id="profile-name-edit-container" style="display: none; align-items: center; gap: 5px; background: rgba(0,0,0,0.5); padding: 5px; border-radius: 4px; width: max-content;"> <div id="profile-name-edit-container" style="display: none; align-items: center; gap: 5px; background: rgba(0,0,0,0.5); padding: 5px; border-radius: 4px; width: max-content;">
<input type="text" id="inline-display-name" value="@if(user.display_name){!! user.display_name !!}@endif" maxlength="32" style="background: #222; border: 1px solid #444; color: #fff; padding: 4px; border-radius: 3px; width: 120px; font-size: 0.9em;"> <input type="text" id="inline-display-name" value="@if(user.display_name){!! user.display_name !!}@endif" maxlength="32" style="background: #222; border: 1px solid #444; color: #fff; padding: 4px; border-radius: 3px; width: 120px; font-size: 0.9em;">
<input type="color" id="inline-username-color" value="{{ user.username_color || '#ffffff' }}" style="width: 25px; height: 25px; padding: 0; border: 1px solid #444; cursor: pointer; background: none;"> <input type="color" id="inline-username-color" value="{{ user.username_color || '#ffffff' }}" style="width: 25px; height: 25px; padding: 0; border: 1px solid #444; cursor: pointer; background: none;">
@@ -87,7 +87,7 @@
<div class="profile_description" style="position: relative;"> <div class="profile_description" style="position: relative;">
<div id="profile-desc-display" style="display: block; margin-bottom: 5px; margin-top: 5px;"> <div id="profile-desc-display" style="display: block; margin-bottom: 5px; margin-top: 5px;">
<span class="desc-text" style="word-break: break-word;">@if(user.description){!! user.description !!}@else<em style="color:var(--text-muted);">No description</em>@endif</span> <span class="desc-text" style="word-break: break-word;">@if(user.description){!! user.description !!}@else<em style="color:var(--text-muted);">No description</em>@endif</span>
@if(session && session.id === user.user_id) @if(session && session.id === user.user_id && !session.is_anon)
<button id="inline-desc-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0; margin-left: 5px; display: inline-block; vertical-align: baseline;" title="Edit Description"> <button id="inline-desc-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0; margin-left: 5px; display: inline-block; vertical-align: baseline;" title="Edit Description">
<i class="fa-solid fa-pen"></i> <i class="fa-solid fa-pen"></i>
</button> </button>
@@ -379,7 +379,7 @@
@endif @endif
@endif @endif
@if(session && session.id === user.user_id) @if(session && session.id === user.user_id && !session.is_anon)
<div id="profile-desc-edit-container" class="modal-overlay" style="display: none;"> <div id="profile-desc-edit-container" class="modal-overlay" style="display: none;">
<div class="modal-content" style="max-width: 600px; width: 90%; text-align: left;"> <div class="modal-content" style="max-width: 600px; width: 90%; text-align: left;">
<h3 style="margin-top: 0; margin-bottom: 15px;">Edit Description</h3> <h3 style="margin-top: 0; margin-bottom: 15px;">Edit Description</h3>
@@ -392,7 +392,7 @@
</div> </div>
@endif @endif
@if(session && session.id === user.user_id) @if(session && session.id === user.user_id && !session.is_anon)
<style> <style>
#live-profile-preview-box:not(.edit-mode-active) .avatar-edit-btn, #live-profile-preview-box:not(.edit-mode-active) .avatar-edit-btn,
#live-profile-preview-box:not(.edit-mode-active) #inline-banner-edit-wrapper, #live-profile-preview-box:not(.edit-mode-active) #inline-banner-edit-wrapper,