gfsd
This commit is contained in:
@@ -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
@@ -83,6 +83,102 @@
|
||||
this.rawPub = null;
|
||||
this.rawSeed = null;
|
||||
this.isSessionReady = false;
|
||||
this.hwFingerprint = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a hardware-level fingerprint based on GPU, CPU, RAM, screen, and Web Audio DSP.
|
||||
* Survives across private browsing windows and different browsers on the same physical device.
|
||||
*/
|
||||
async getHardwareFingerprint() {
|
||||
if (this.hwFingerprint) return this.hwFingerprint;
|
||||
|
||||
try {
|
||||
let glVendor = '';
|
||||
let glRenderer = '';
|
||||
let glLimits = '';
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
if (gl) {
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if (ext) {
|
||||
glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || '';
|
||||
glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '';
|
||||
}
|
||||
glLimits = [
|
||||
gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
|
||||
gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0,
|
||||
gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0,
|
||||
gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0,
|
||||
gl.getParameter(gl.MAX_VARYING_VECTORS) || 0,
|
||||
gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0
|
||||
].join(',');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const concurrency = navigator.hardwareConcurrency || 0;
|
||||
const memory = navigator.deviceMemory || 0;
|
||||
const screenInfo = [
|
||||
window.screen ? window.screen.width : 0,
|
||||
window.screen ? window.screen.height : 0,
|
||||
window.screen ? window.screen.colorDepth : 0,
|
||||
window.screen ? window.screen.pixelDepth : 0,
|
||||
window.devicePixelRatio || 1
|
||||
].join('x');
|
||||
|
||||
let audioFp = '';
|
||||
try {
|
||||
const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||||
if (AudioContext) {
|
||||
const actx = new AudioContext(1, 44100, 44100);
|
||||
const osc = actx.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(10000, actx.currentTime);
|
||||
|
||||
const comp = actx.createDynamicsCompressor();
|
||||
comp.threshold.setValueAtTime(-50, actx.currentTime);
|
||||
comp.knee.setValueAtTime(40, actx.currentTime);
|
||||
comp.ratio.setValueAtTime(12, actx.currentTime);
|
||||
comp.reduction.setValueAtTime(-20, actx.currentTime);
|
||||
comp.attack.setValueAtTime(0, actx.currentTime);
|
||||
comp.release.setValueAtTime(0.25, actx.currentTime);
|
||||
|
||||
osc.connect(comp);
|
||||
comp.connect(actx.destination);
|
||||
osc.start(0);
|
||||
|
||||
const renderedBuffer = await actx.startRendering();
|
||||
const channel = renderedBuffer.getChannelData(0);
|
||||
let sum = 0;
|
||||
const end = Math.min(channel.length, 5000);
|
||||
for (let i = 4500; i < end; i++) {
|
||||
sum += Math.abs(channel[i] || 0);
|
||||
}
|
||||
audioFp = sum.toFixed(7);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const rawHardware = [
|
||||
glVendor,
|
||||
glRenderer,
|
||||
glLimits,
|
||||
concurrency,
|
||||
memory,
|
||||
screenInfo,
|
||||
audioFp
|
||||
].join('~~~');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(rawHardware);
|
||||
const hashBuf = await window.crypto.subtle.digest('SHA-256', data);
|
||||
const hashHex = bytesToHex(new Uint8Array(hashBuf));
|
||||
this.hwFingerprint = `HW:${hashHex}`;
|
||||
return this.hwFingerprint;
|
||||
} catch (err) {
|
||||
console.warn('[ANON_SSH] Failed to compute hardware fingerprint:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getDomain() {
|
||||
@@ -430,6 +526,8 @@
|
||||
const timestamp = Date.now();
|
||||
const message = `anon-auth:${timestamp}:${this.pubkey}`;
|
||||
const signature = await this.sign(message);
|
||||
const tombstone = this.getTombstone();
|
||||
const hwFingerprint = await this.getHardwareFingerprint();
|
||||
|
||||
const res = await fetch('/api/v2/anon/session', {
|
||||
method: 'POST',
|
||||
@@ -437,11 +535,27 @@
|
||||
body: JSON.stringify({
|
||||
pubkey: this.pubkey,
|
||||
timestamp: timestamp,
|
||||
signature: signature
|
||||
signature: signature,
|
||||
tombstone: tombstone,
|
||||
hw_fingerprint: hwFingerprint
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.banned) {
|
||||
this.setTombstone({
|
||||
banned: true,
|
||||
fingerprint: data.fingerprint || this.fingerprint,
|
||||
hw_fingerprint: data.hw_fingerprint || hwFingerprint,
|
||||
reason: data.reason,
|
||||
expires: data.expires
|
||||
});
|
||||
if (window.location.pathname !== '/banned') {
|
||||
window.location.href = data.redirect || '/banned';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
this.isSessionReady = true;
|
||||
if (data.csrf_token) {
|
||||
@@ -452,7 +566,8 @@
|
||||
window.f0ckAnonIdentity = {
|
||||
userId: data.user_id,
|
||||
fingerprint: data.fingerprint,
|
||||
shortFingerprint: data.short_fingerprint
|
||||
shortFingerprint: data.short_fingerprint,
|
||||
hwFingerprint: data.hw_fingerprint || hwFingerprint
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity }));
|
||||
}
|
||||
@@ -461,6 +576,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client ban tombstone if previously banned.
|
||||
*/
|
||||
getTombstone() {
|
||||
try {
|
||||
const raw = localStorage.getItem('f0ck_anon_tombstone');
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save client ban tombstone.
|
||||
*/
|
||||
setTombstone(tombstone) {
|
||||
try {
|
||||
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(tombstone));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger browser download of id_ed25519 private key file
|
||||
*/
|
||||
|
||||
@@ -1289,6 +1289,33 @@
|
||||
}
|
||||
};
|
||||
|
||||
const enforceSidebarRecommendationDistribution = (items) => {
|
||||
if (!Array.isArray(items) || items.length <= 3) return items;
|
||||
// Collect indices of personalized recommendations in the first 3 items
|
||||
const recIndicesInFirst3 = [];
|
||||
for (let i = 0; i < Math.min(3, items.length); i++) {
|
||||
if (items[i] && items[i].personalized) recIndicesInFirst3.push(i);
|
||||
}
|
||||
|
||||
// If more than 1 recommendation in first 3, swap extras with non-rec items from index >= 3
|
||||
if (recIndicesInFirst3.length > 1) {
|
||||
let nonRecIdx = 3;
|
||||
for (let r = 1; r < recIndicesInFirst3.length; r++) {
|
||||
const swapSlot = recIndicesInFirst3[r];
|
||||
while (nonRecIdx < items.length && items[nonRecIdx] && items[nonRecIdx].personalized) {
|
||||
nonRecIdx++;
|
||||
}
|
||||
if (nonRecIdx < items.length) {
|
||||
const tmp = items[swapSlot];
|
||||
items[swapSlot] = items[nonRecIdx];
|
||||
items[nonRecIdx] = tmp;
|
||||
nonRecIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const loadRecommendations = async (silent = false) => {
|
||||
const container = document.getElementById('sidebar-recommendations-container');
|
||||
if (!container || recommendationsLoading) return;
|
||||
@@ -1316,6 +1343,7 @@
|
||||
const items = data.items || data.videos || [];
|
||||
|
||||
if (data.success && items.length > 0) {
|
||||
enforceSidebarRecommendationDistribution(items);
|
||||
currentRecommendations = items;
|
||||
recommendationsLoaded = true;
|
||||
|
||||
@@ -1368,7 +1396,7 @@
|
||||
const affParams = getSessionAffinityParams();
|
||||
const mime = getCurrentMimeFilter();
|
||||
const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : '';
|
||||
const res = await fetch(`/api/v2/recommendations?limit=15&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}${affParams}`, {
|
||||
const res = await fetch(`/api/v2/recommendations?limit=15&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}&continuation=1${affParams}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -1877,10 +1905,28 @@
|
||||
}
|
||||
const excludeArr = Array.from(excludeSet).slice(-120);
|
||||
|
||||
const cards = Array.from(container ? container.querySelectorAll('.sidebar-video-card') : []);
|
||||
const cardIndex = cards.indexOf(card);
|
||||
let preferParam = '';
|
||||
if (cardIndex >= 0 && cardIndex < 3) {
|
||||
// Keep exactly 1 recommendation in the first 3 cards
|
||||
const isPersonalized = card.dataset.personalized === 'true';
|
||||
preferParam = `&prefer_personalized=${isPersonalized ? 'true' : 'false'}`;
|
||||
} else if (cardIndex >= 3) {
|
||||
// Ensure recommendations remain sporadic (never consecutive)
|
||||
const prevCard = cards[cardIndex - 1];
|
||||
const nextCard = cards[cardIndex + 1];
|
||||
const prevIsRec = prevCard && prevCard.dataset.personalized === 'true';
|
||||
const nextIsRec = nextCard && nextCard.dataset.personalized === 'true';
|
||||
if (prevIsRec || nextIsRec) {
|
||||
preferParam = '&prefer_personalized=false';
|
||||
}
|
||||
}
|
||||
|
||||
const affParams = getSessionAffinityParams();
|
||||
const mime = getCurrentMimeFilter();
|
||||
const mimeParam = mime ? `&mime=${encodeURIComponent(mime)}` : '';
|
||||
const res = await fetch(`/api/v2/recommendations?limit=1&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}${affParams}`, {
|
||||
const res = await fetch(`/api/v2/recommendations?limit=1&mode=${mode}${mimeParam}&exclude_ids=${excludeArr.join(',')}${preferParam}${affParams}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
+74
-6
@@ -86,14 +86,57 @@ export function verifySignature(sshPubkey, message, signature) {
|
||||
}
|
||||
}
|
||||
|
||||
import security from './security.mjs';
|
||||
import { getHashUserIps } from './settings.mjs';
|
||||
|
||||
/**
|
||||
* Get IP for audit/logging, hashed if hash_user_ips is enabled in config.
|
||||
* @param {object} req
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveAuditIP(req) {
|
||||
if (!req) return 'unknown';
|
||||
const rawIp = security.getRealIP(req);
|
||||
return getHashUserIps() ? security.hashIP(rawIp) : rawIp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log activity for an anonymous user (or session).
|
||||
* @param {object} req
|
||||
* @param {{ action: string, targetId?: number|string, details?: object, hwFingerprint?: string }} params
|
||||
*/
|
||||
export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) {
|
||||
try {
|
||||
const rawIp = security.getRealIP(req);
|
||||
const ip = getHashUserIps() ? security.hashIP(rawIp) : rawIp;
|
||||
const userId = req?.session?.id || null;
|
||||
if (!userId) return;
|
||||
const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null;
|
||||
const hwFp = hwFingerprint || req?.session?.hw_fingerprint || null;
|
||||
const numTargetId = targetId ? parseInt(targetId, 10) : null;
|
||||
|
||||
await db`
|
||||
INSERT INTO anon_activity_log (user_id, fingerprint, hw_fingerprint, ip, action, target_id, details)
|
||||
VALUES (${userId}, ${fingerprint}, ${hwFp}, ${ip}, ${action}, ${!isNaN(numTargetId) ? numTargetId : null}, ${details ? JSON.stringify(details) : null})
|
||||
`;
|
||||
|
||||
await security.logUserIP(userId, rawIp);
|
||||
} catch (err) {
|
||||
console.error('[ANON_ACTIVITY_LOG] Failed to log activity:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a shadow user in the database for an anonymous SSH identity.
|
||||
* @param {string} pubkey
|
||||
* @param {string} fingerprint
|
||||
* @param {object} [req]
|
||||
* @param {string} [hwFingerprint]
|
||||
* @returns {Promise<{ userId: number, isNew: boolean }>}
|
||||
*/
|
||||
export async function getOrCreateAnonUser(pubkey, fingerprint) {
|
||||
export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFingerprint = null) {
|
||||
const normPubkey = pubkey.trim();
|
||||
const auditIp = req ? resolveAuditIP(req) : null;
|
||||
const existing = await db`
|
||||
SELECT user_id FROM anon_identities
|
||||
WHERE pubkey = ${normPubkey}
|
||||
@@ -101,7 +144,13 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) {
|
||||
`;
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db`UPDATE anon_identities SET last_seen = NOW() WHERE pubkey = ${normPubkey}`;
|
||||
await db`
|
||||
UPDATE anon_identities
|
||||
SET last_seen = NOW()
|
||||
${auditIp ? db`, last_ip = ${auditIp}` : db``}
|
||||
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
|
||||
WHERE pubkey = ${normPubkey}
|
||||
`;
|
||||
return { userId: existing[0].user_id, isNew: false };
|
||||
}
|
||||
|
||||
@@ -131,9 +180,12 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) {
|
||||
`;
|
||||
|
||||
await db`
|
||||
INSERT INTO anon_identities (user_id, pubkey, fingerprint)
|
||||
VALUES (${userId}, ${normPubkey}, ${fingerprint})
|
||||
ON CONFLICT (pubkey) DO NOTHING
|
||||
INSERT INTO anon_identities (user_id, pubkey, fingerprint, created_ip, last_ip, hw_fingerprint)
|
||||
VALUES (${userId}, ${normPubkey}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
|
||||
ON CONFLICT (pubkey) DO UPDATE
|
||||
SET last_seen = NOW()
|
||||
${auditIp ? db`, last_ip = ${auditIp}` : db``}
|
||||
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
|
||||
`;
|
||||
|
||||
return { userId, isNew: true };
|
||||
@@ -143,11 +195,24 @@ export async function getOrCreateAnonUser(pubkey, fingerprint) {
|
||||
* Create a valid session in user_sessions for this anonymous user.
|
||||
* @param {number} userId
|
||||
* @param {object} req
|
||||
* @param {string} [hwFingerprint]
|
||||
* @returns {Promise<{ session: string, csrf_token: string }>}
|
||||
*/
|
||||
export async function createAnonSession(userId, req) {
|
||||
export async function createAnonSession(userId, req, hwFingerprint = null) {
|
||||
const auditIp = resolveAuditIP(req);
|
||||
|
||||
// Update anon_identities last_ip, created_ip, and hw_fingerprint
|
||||
await db`
|
||||
UPDATE anon_identities
|
||||
SET last_ip = ${auditIp},
|
||||
created_ip = COALESCE(created_ip, ${auditIp})
|
||||
${hwFingerprint ? db`, hw_fingerprint = COALESCE(${hwFingerprint}, hw_fingerprint)` : db``}
|
||||
WHERE user_id = ${userId}
|
||||
`.catch(() => {});
|
||||
|
||||
// 1. If req.session is already active for this exact userId, reuse it!
|
||||
if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) {
|
||||
await logAnonActivity(req, { action: 'handshake', hwFingerprint });
|
||||
return { session: req.cookies.session, csrf_token: req.session.csrf_token };
|
||||
}
|
||||
|
||||
@@ -161,6 +226,7 @@ export async function createAnonSession(userId, req) {
|
||||
`;
|
||||
if (existing.length > 0) {
|
||||
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`;
|
||||
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
|
||||
return { session: req.cookies.session, csrf_token: existing[0].csrf_token };
|
||||
}
|
||||
}
|
||||
@@ -188,5 +254,7 @@ export async function createAnonSession(userId, req) {
|
||||
INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}
|
||||
`;
|
||||
|
||||
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
|
||||
|
||||
return { session, csrf_token: csrfToken };
|
||||
}
|
||||
|
||||
@@ -513,6 +513,7 @@
|
||||
"stat_favs": "Gesamt Favoriten",
|
||||
"stat_disk_usage": "Dateigröße Gesamt",
|
||||
"stat_users": "Gesamt Benutzer",
|
||||
"stat_anon_users": "Anonyme Benutzer",
|
||||
"most_favorited": "Meiste Favs",
|
||||
"favs": "Favs",
|
||||
"top_xd": "Top xD-Score"
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
"stat_favs": "Total Favorites",
|
||||
"stat_disk_usage": "Total File Size",
|
||||
"stat_users": "Total Users",
|
||||
"stat_anon_users": "Anonymous Users",
|
||||
"most_favorited": "Most Favorited",
|
||||
"favs": "favs",
|
||||
"top_xd": "Top xD Scores"
|
||||
|
||||
@@ -511,6 +511,7 @@
|
||||
"stat_favs": "Totaal aantal favorieten",
|
||||
"stat_disk_usage": "Totale Bestandsgrootte",
|
||||
"stat_users": "Totaal Gebruikers",
|
||||
"stat_anon_users": "Anonieme gebruikers",
|
||||
"most_favorited": "Meest Gefavoriet",
|
||||
"favs": "favorieten",
|
||||
"top_xd": "Top xD-scores"
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
"stat_favs": "Gesamtanzahl Favoriten",
|
||||
"stat_disk_usage": "Dateigröße Gesamt",
|
||||
"stat_users": "Gesamt Benutzer",
|
||||
"stat_anon_users": "Anonymer Alkoholiker",
|
||||
"most_favorited": "Am häufigsten favorisiert",
|
||||
"favs": "Favoriten",
|
||||
"top_xd": "Beste xD-Punktestände"
|
||||
|
||||
@@ -2328,7 +2328,9 @@ const f0cklib = {
|
||||
mime,
|
||||
exclude_ids,
|
||||
session_tags = '',
|
||||
session_creators = ''
|
||||
session_creators = '',
|
||||
continuation = false,
|
||||
prefer_personalized = null
|
||||
} = {}) => {
|
||||
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||
const modequery = computeBaseMode(mode, ratingsArr, session);
|
||||
@@ -2420,13 +2422,30 @@ const f0cklib = {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Personalization vs Serendipity Split (60% personalized, 40% random exploration)
|
||||
// 2. Personalization vs Serendipity Split
|
||||
// In the sidebar suggestions:
|
||||
// - 1 of the first 3 recommendations is a personalized recommendation and the other 2 are not.
|
||||
// - After that, recommendations appear sporadically (~20% rate, non-consecutive with 3-5 random items between them).
|
||||
const isFirstBatch = !continuation && excludeItemIds.length === 0;
|
||||
let personalizedTarget;
|
||||
|
||||
if (maxLimit === 1) {
|
||||
// For single-card replacement: 65% chance personalized, 35% chance discovery
|
||||
personalizedTarget = Math.random() < 0.65 ? 1 : 0;
|
||||
if (prefer_personalized === true) {
|
||||
personalizedTarget = 1;
|
||||
} else if (prefer_personalized === false) {
|
||||
personalizedTarget = 0;
|
||||
} else {
|
||||
personalizedTarget = Math.round(maxLimit * 0.60);
|
||||
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 {
|
||||
// Continuation batch: sporadic recommendations throughout
|
||||
personalizedTarget = Math.max(1, Math.round(maxLimit * 0.20));
|
||||
}
|
||||
|
||||
const targetTagIds = targetTagEntries.map(([tid]) => tid);
|
||||
@@ -2537,8 +2556,9 @@ const f0cklib = {
|
||||
let randomItems = [];
|
||||
if (neededRandom > 0) {
|
||||
const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)];
|
||||
// Fetch extra buffer of random items to ensure ample spacing
|
||||
randomItems = await f0cklib.getRandomRecommendations({
|
||||
limit: neededRandom,
|
||||
limit: Math.max(neededRandom + 3, maxLimit),
|
||||
mode,
|
||||
ratings,
|
||||
session,
|
||||
@@ -2550,14 +2570,74 @@ const f0cklib = {
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Combine & Interweave with Fisher-Yates Shuffle
|
||||
const combined = [...personalizedItems, ...randomItems];
|
||||
for (let i = combined.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[combined[i], combined[j]] = [combined[j], combined[i]];
|
||||
// 4. Combine & Interweave
|
||||
if (maxLimit === 1) {
|
||||
return personalizedItems.length > 0 ? personalizedItems.slice(0, 1) : randomItems.slice(0, 1);
|
||||
}
|
||||
|
||||
return combined.slice(0, maxLimit);
|
||||
if (personalizedItems.length === 0) {
|
||||
return randomItems.slice(0, maxLimit);
|
||||
}
|
||||
|
||||
if (randomItems.length === 0) {
|
||||
return personalizedItems.slice(0, maxLimit);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
if (isFirstBatch) {
|
||||
// First 3 items: exactly 1 is a recommendation, and the other 2 are not
|
||||
const topCount = Math.min(3, maxLimit);
|
||||
const topSlots = new Array(topCount);
|
||||
const recSlot = Math.floor(Math.random() * topCount);
|
||||
topSlots[recSlot] = personalizedItems.shift();
|
||||
|
||||
for (let s = 0; s < topCount; s++) {
|
||||
if (s !== recSlot) {
|
||||
topSlots[s] = randomItems.length > 0 ? randomItems.shift() : personalizedItems.shift();
|
||||
}
|
||||
}
|
||||
for (let s = 0; s < topCount; s++) {
|
||||
if (topSlots[s]) result.push(topSlots[s]);
|
||||
}
|
||||
|
||||
// Remaining slots: recommendations come sporadically (separated by 3 to 5 non-recommendations)
|
||||
let gapSinceRec = topCount - 1 - recSlot;
|
||||
let targetGap = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5 random items
|
||||
|
||||
while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) {
|
||||
if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) {
|
||||
result.push(personalizedItems.shift());
|
||||
gapSinceRec = 0;
|
||||
targetGap = Math.floor(Math.random() * 3) + 3;
|
||||
} else if (randomItems.length > 0) {
|
||||
result.push(randomItems.shift());
|
||||
gapSinceRec++;
|
||||
} else if (personalizedItems.length > 0) {
|
||||
result.push(personalizedItems.shift());
|
||||
gapSinceRec = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continuation batch: recommendations come sporadically throughout
|
||||
let gapSinceRec = Math.floor(Math.random() * 2) + 1;
|
||||
let targetGap = Math.floor(Math.random() * 3) + 3;
|
||||
|
||||
while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) {
|
||||
if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) {
|
||||
result.push(personalizedItems.shift());
|
||||
gapSinceRec = 0;
|
||||
targetGap = Math.floor(Math.random() * 3) + 3;
|
||||
} else if (randomItems.length > 0) {
|
||||
result.push(randomItems.shift());
|
||||
gapSinceRec++;
|
||||
} else if (personalizedItems.length > 0) {
|
||||
result.push(personalizedItems.shift());
|
||||
gapSinceRec = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, maxLimit);
|
||||
},
|
||||
|
||||
getTagFeedItems: async ({
|
||||
|
||||
@@ -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) => {
|
||||
const userId = +req.params.userId;
|
||||
const user = await db`select "user", login from "user" where id = ${userId} limit 1`;
|
||||
@@ -481,6 +671,19 @@ export default (router, tpl) => {
|
||||
where id = ${+user_id}
|
||||
`;
|
||||
|
||||
// If this is an anonymous identity, cascade the ban to fingerprint and IPs
|
||||
const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`;
|
||||
if (anonIdent.length > 0 || (targetUser[0].login && targetUser[0].login.startsWith('anon_'))) {
|
||||
await security.banAnonymousUser({
|
||||
userId: +user_id,
|
||||
fingerprint: anonIdent[0]?.fingerprint || null,
|
||||
bannedBy: req.session.id,
|
||||
reason,
|
||||
expires,
|
||||
banIps: true
|
||||
});
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
await audit.log(req.session.id, 'ban_user', 'user', +user_id, { reason, duration, target_user: targetUser[0].user });
|
||||
|
||||
@@ -516,6 +719,12 @@ export default (router, tpl) => {
|
||||
where id = ${+user_id}
|
||||
`;
|
||||
|
||||
// Clean up any banned fingerprint for this identity
|
||||
const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`;
|
||||
if (anonIdent.length > 0) {
|
||||
await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${anonIdent[0].fingerprint}`;
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
await audit.log(req.session.id, 'unban_user', 'user', +user_id);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import security from '../../security.mjs';
|
||||
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs';
|
||||
import { getEnableAnonymousAccess } from '../../settings.mjs';
|
||||
|
||||
@@ -17,6 +18,19 @@ export default router => {
|
||||
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
||||
}
|
||||
|
||||
const clientIp = security.getRealIP(req);
|
||||
const ipBan = await security.isIpBanned(clientIp);
|
||||
if (ipBan) {
|
||||
return res.json({
|
||||
success: false,
|
||||
banned: true,
|
||||
msg: 'YOU ARE BANNED!',
|
||||
reason: ipBan.reason || 'IP address is banned',
|
||||
expires: ipBan.expires ? new Date(ipBan.expires).toLocaleString() : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}, 403);
|
||||
}
|
||||
|
||||
const body = req.post || req.body || {};
|
||||
const pubkey = (body.pubkey || '').trim();
|
||||
const timestamp = parseInt(body.timestamp, 10);
|
||||
@@ -39,8 +53,119 @@ export default router => {
|
||||
}
|
||||
|
||||
const parsed = parseOpenSshPubkey(pubkey);
|
||||
const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint);
|
||||
const { session, csrf_token } = await createAnonSession(userId, req);
|
||||
const hwFingerprint = (body.hw_fingerprint || '').trim() || null;
|
||||
|
||||
// Check tombstone token sent from client (fingerprint or hardware ID)
|
||||
if (body.tombstone && body.tombstone.banned) {
|
||||
const tombstoneFp = body.tombstone.fingerprint;
|
||||
const tombstoneHw = body.tombstone.hw_fingerprint;
|
||||
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
|
||||
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
|
||||
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
|
||||
|
||||
if (activeTombstoneBan) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint: hwFingerprint || tombstoneHw,
|
||||
bannedBy: activeTombstoneBan.banned_by,
|
||||
reason: `Cascade ban from device (${activeTombstoneBan.reason || 'Banned'})`,
|
||||
expires: activeTombstoneBan.expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
return res.json({
|
||||
success: false,
|
||||
banned: true,
|
||||
fingerprint: parsed.fingerprint,
|
||||
hw_fingerprint: hwFingerprint || tombstoneHw,
|
||||
msg: 'YOU ARE BANNED!',
|
||||
reason: activeTombstoneBan.reason || 'Device is banned',
|
||||
expires: activeTombstoneBan.expires ? new Date(activeTombstoneBan.expires).toLocaleString() : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Check hardware fingerprint ban
|
||||
if (hwFingerprint) {
|
||||
const hwBan = await security.isHardwareBanned(hwFingerprint);
|
||||
if (hwBan) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
bannedBy: hwBan.banned_by,
|
||||
reason: `Cascade ban from hardware ID (${hwBan.reason || 'Banned'})`,
|
||||
expires: hwBan.expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
return res.json({
|
||||
success: false,
|
||||
banned: true,
|
||||
fingerprint: parsed.fingerprint,
|
||||
hw_fingerprint: hwFingerprint,
|
||||
msg: 'YOU ARE BANNED!',
|
||||
reason: hwBan.reason || 'Hardware ID is banned',
|
||||
expires: hwBan.expires ? new Date(hwBan.expires).toLocaleString() : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Check fingerprint ban
|
||||
const fpBan = await security.isFingerprintBanned(parsed.fingerprint);
|
||||
if (fpBan) {
|
||||
if (hwFingerprint) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
bannedBy: fpBan.banned_by,
|
||||
reason: `Cascade ban from key (${fpBan.reason || 'Banned'})`,
|
||||
expires: fpBan.expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
success: false,
|
||||
banned: true,
|
||||
fingerprint: parsed.fingerprint,
|
||||
hw_fingerprint: hwFingerprint,
|
||||
msg: 'YOU ARE BANNED!',
|
||||
reason: fpBan.reason || 'Key fingerprint is banned',
|
||||
expires: fpBan.expires ? new Date(fpBan.expires).toLocaleString() : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}, 403);
|
||||
}
|
||||
|
||||
const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint, req, hwFingerprint);
|
||||
|
||||
// Check user table ban
|
||||
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
|
||||
if (userRows.length > 0 && userRows[0].banned) {
|
||||
const u = userRows[0];
|
||||
await security.banAnonymousUser({
|
||||
userId,
|
||||
fingerprint: parsed.fingerprint,
|
||||
hwFingerprint,
|
||||
reason: u.ban_reason || 'Banned',
|
||||
expires: u.ban_expires,
|
||||
banIps: true,
|
||||
banHardware: true
|
||||
});
|
||||
return res.json({
|
||||
success: false,
|
||||
banned: true,
|
||||
fingerprint: parsed.fingerprint,
|
||||
hw_fingerprint: hwFingerprint,
|
||||
msg: 'YOU ARE BANNED!',
|
||||
reason: u.ban_reason || 'Banned',
|
||||
expires: u.ban_expires ? new Date(u.ban_expires).toLocaleString() : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}, 403);
|
||||
}
|
||||
|
||||
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint);
|
||||
|
||||
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
|
||||
|
||||
@@ -50,6 +175,7 @@ export default router => {
|
||||
user_id: userId,
|
||||
fingerprint: parsed.fingerprint,
|
||||
short_fingerprint: parsed.shortFingerprint,
|
||||
hw_fingerprint: hwFingerprint,
|
||||
csrf_token: csrf_token
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -73,7 +199,7 @@ export default router => {
|
||||
}
|
||||
|
||||
const rows = await db`
|
||||
SELECT pubkey, fingerprint, created_at, last_seen
|
||||
SELECT pubkey, fingerprint, hw_fingerprint, created_at, last_seen
|
||||
FROM anon_identities
|
||||
WHERE user_id = ${req.session.id}
|
||||
LIMIT 1
|
||||
@@ -87,6 +213,7 @@ export default router => {
|
||||
user_id: req.session.id,
|
||||
fingerprint: fp,
|
||||
short_fingerprint: fp.slice(7, 15),
|
||||
hw_fingerprint: rows[0].hw_fingerprint,
|
||||
pubkey: rows[0].pubkey,
|
||||
csrf_token: req.session.csrf_token
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
import { purgeExpiredUploads } from '../../lib_delete.mjs';
|
||||
import { calculateExpiresAt } from './upload.mjs';
|
||||
import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs';
|
||||
import { logAnonActivity } from '../../anon_auth.mjs';
|
||||
|
||||
const allowedMimes = ["audio", "image", "video", "%"];
|
||||
const getGlobalfilter = () => {
|
||||
@@ -730,6 +731,13 @@ export default router => {
|
||||
|
||||
const sessionTags = req.url.qs?.session_tags || '';
|
||||
const sessionCreators = req.url.qs?.session_creators || '';
|
||||
const continuation = req.url.qs?.continuation === '1' || req.url.qs?.continuation === 'true';
|
||||
let preferPersonalized = null;
|
||||
if (req.url.qs?.prefer_personalized === 'true' || req.url.qs?.prefer_personalized === '1') {
|
||||
preferPersonalized = true;
|
||||
} else if (req.url.qs?.prefer_personalized === 'false' || req.url.qs?.prefer_personalized === '0') {
|
||||
preferPersonalized = false;
|
||||
}
|
||||
|
||||
const items = await f0cklib.getPersonalizedRecommendations({
|
||||
limit,
|
||||
@@ -742,7 +750,9 @@ export default router => {
|
||||
mime,
|
||||
exclude_ids: excludeIds,
|
||||
session_tags: sessionTags,
|
||||
session_creators: sessionCreators
|
||||
session_creators: sessionCreators,
|
||||
continuation,
|
||||
prefer_personalized: preferPersonalized
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -1381,6 +1391,9 @@ export default router => {
|
||||
and item_id = ${+postid}
|
||||
`;
|
||||
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: -5.0 }).catch(() => {});
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, { action: 'unfavorite', targetId: postid });
|
||||
}
|
||||
} else {
|
||||
// add fav — ON CONFLICT DO NOTHING guards against rapid double-taps
|
||||
await db`
|
||||
@@ -1391,6 +1404,9 @@ export default router => {
|
||||
on conflict do nothing
|
||||
`;
|
||||
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: 5.0 }).catch(() => {});
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, { action: 'favorite', targetId: postid });
|
||||
}
|
||||
}
|
||||
|
||||
const favs = await db`
|
||||
@@ -1452,6 +1468,10 @@ export default router => {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, { action: 'favorites_import', details: { count } });
|
||||
}
|
||||
|
||||
return res.json({ success: true, imported: count });
|
||||
} catch (err) {
|
||||
console.error('[FAVORITES_IMPORT_ERROR]', err);
|
||||
|
||||
@@ -5,6 +5,7 @@ import queue from "../../queue.mjs";
|
||||
import cfg from "../../config.mjs";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { logAnonActivity } from "../../anon_auth.mjs";
|
||||
|
||||
export default router => {
|
||||
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
|
||||
@@ -86,6 +87,13 @@ export default router => {
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(postid);
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'tag',
|
||||
targetId: postid,
|
||||
details: { tag: tagname }
|
||||
});
|
||||
}
|
||||
console.log(`[API] Notifying 'tags' for item ${postid} with ${freshTags.length} tags`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
@@ -176,6 +184,13 @@ export default router => {
|
||||
const { label, cls } = labels[nextTagId] || { label: 'SFW', cls: 'sfw' };
|
||||
|
||||
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }).catch(() => {});
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'cycle_rating',
|
||||
targetId: postid,
|
||||
details: { from: ratingTagId, to: nextTagId, rating_label: label }
|
||||
});
|
||||
}
|
||||
const freshTags = await lib.getTags(postid);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {});
|
||||
|
||||
@@ -281,6 +296,13 @@ export default router => {
|
||||
if (reply) {
|
||||
const reason = req.post.reason || req.url.qs.reason || 'No reason provided';
|
||||
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, reason });
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'delete_tag',
|
||||
targetId: postid,
|
||||
details: { tag: tagname, reason }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(postid);
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/banned\/?$/, async (req, res) => {
|
||||
if (!req.session || !req.session.banned) {
|
||||
let isBanned = false;
|
||||
let reason = 'Violation of community rules';
|
||||
let expires = null;
|
||||
|
||||
if (req.session && req.session.banned) {
|
||||
isBanned = true;
|
||||
reason = req.session.ban_reason || reason;
|
||||
expires = req.session.ban_expires;
|
||||
}
|
||||
|
||||
const clientIp = security.getRealIP(req);
|
||||
const ipBan = await security.isIpBanned(clientIp);
|
||||
if (ipBan) {
|
||||
isBanned = true;
|
||||
reason = ipBan.reason || reason;
|
||||
expires = ipBan.expires;
|
||||
}
|
||||
|
||||
const fp = req.session?.fingerprint || req.session?.anon_fingerprint;
|
||||
if (fp) {
|
||||
const fpBan = await security.isFingerprintBanned(fp);
|
||||
if (fpBan) {
|
||||
isBanned = true;
|
||||
reason = fpBan.reason || reason;
|
||||
expires = fpBan.expires;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBanned) {
|
||||
return res.writeHead(302, {
|
||||
"Location": "/"
|
||||
}).end();
|
||||
@@ -11,8 +40,8 @@ export default (router, tpl) => {
|
||||
res.reply({
|
||||
body: tpl.render("banned", {
|
||||
session: req.session,
|
||||
reason: req.session.ban_reason,
|
||||
expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent',
|
||||
reason: reason,
|
||||
expires: expires ? new Date(expires).toLocaleString() : 'Permanent',
|
||||
ban_video: cfg.websrv.ban_video,
|
||||
hideNavbar: true
|
||||
}, req)
|
||||
|
||||
@@ -7,7 +7,7 @@ import audit from "../audit.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import { applyWordFilter } from "../wordfilter.mjs";
|
||||
import path from "path";
|
||||
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } from "../anon_auth.mjs";
|
||||
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
|
||||
import { getEnableAnonymousAccess } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
@@ -457,11 +457,13 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
|
||||
const auditIp = resolveAuditIP(req);
|
||||
const insertData = {
|
||||
item_id,
|
||||
user_id: req.session.id,
|
||||
parent_id: parent_id || null,
|
||||
content: content || ''
|
||||
content: content || '',
|
||||
ip: auditIp
|
||||
};
|
||||
if (video_time !== null) insertData.video_time = video_time;
|
||||
|
||||
@@ -472,6 +474,14 @@ export default (router, tpl) => {
|
||||
|
||||
const commentId = parseInt(newComment[0].id, 10);
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'comment',
|
||||
targetId: commentId,
|
||||
details: { item_id, parent_id }
|
||||
});
|
||||
}
|
||||
|
||||
// Link uploaded files to this comment (if any)
|
||||
let activityFiles = [];
|
||||
const fileIdsRaw = body.file_ids || '';
|
||||
@@ -785,6 +795,14 @@ export default (router, tpl) => {
|
||||
old_content: comment[0].content
|
||||
});
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'comment_delete',
|
||||
targetId: commentId,
|
||||
details: { item_id: comment[0].item_id }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle attachments cleanup
|
||||
const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`;
|
||||
for (const f of files) {
|
||||
@@ -1451,6 +1469,14 @@ export default (router, tpl) => {
|
||||
`;
|
||||
}
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'poll_vote',
|
||||
targetId: pollId,
|
||||
details: { option_id: optionId }
|
||||
});
|
||||
}
|
||||
|
||||
// Return updated tally
|
||||
const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
|
||||
const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true;
|
||||
|
||||
@@ -59,6 +59,11 @@ export default (router, tpl) => {
|
||||
from "tags_assign"
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join "user_options" on "user_options".user_id = "user".id
|
||||
left join "anon_identities" on "anon_identities".user_id = "user".id
|
||||
where "user".id is not null
|
||||
and "anon_identities".id is null
|
||||
and "user".login not like 'anon_%'
|
||||
and "user".user != 'anonymous'
|
||||
group by "user".user, "user_options".avatar, "user_options".avatar_file, "user".admin, "user_options".display_name
|
||||
order by count desc
|
||||
`;
|
||||
@@ -81,6 +86,15 @@ export default (router, tpl) => {
|
||||
const totalUsers = +(await db`
|
||||
select count(*) as total
|
||||
from "user"
|
||||
left join "anon_identities" on "anon_identities".user_id = "user".id
|
||||
where "anon_identities".id is null
|
||||
and "user".login not like 'anon_%'
|
||||
and "user".user != 'anonymous'
|
||||
`)[0].total;
|
||||
|
||||
const totalAnonUsers = +(await db`
|
||||
select count(distinct pubkey) as total
|
||||
from anon_identities
|
||||
`)[0].total;
|
||||
|
||||
const hoster = await db`
|
||||
@@ -141,6 +155,7 @@ export default (router, tpl) => {
|
||||
totalComments,
|
||||
totalFavs,
|
||||
totalUsers,
|
||||
totalAnonUsers,
|
||||
enable_nsfl: config.enable_nsfl,
|
||||
diskSize: cachedDiskSize,
|
||||
tmp: null,
|
||||
|
||||
@@ -190,4 +190,209 @@ export default new class {
|
||||
on conflict (user_id, ip) do update set last_seen = now()
|
||||
`.catch(err => console.error(`[SECURITY] Failed to log user IP:`, err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is banned in banned_ips.
|
||||
* Checks both raw IP and hashed IP.
|
||||
* @param {string} ip
|
||||
* @returns {Promise<{ id: number, ip_address: string, reason: string, expires: Date|null }|null>}
|
||||
*/
|
||||
async isIpBanned(ip) {
|
||||
if (!ip || ip === "unknown") return null;
|
||||
if (cfg.main.development && ip === "127.0.0.1" && !cfg.test_ban_localhost) return null;
|
||||
try {
|
||||
const ipHash = this.hashIP(ip);
|
||||
const rows = await db`
|
||||
select id, ip, ip_hash, reason, expires_at as expires
|
||||
from banned_ips
|
||||
where (ip = ${ip} or ip = ${ipHash} or ip_hash = ${ipHash} or ip_hash = ${ip})
|
||||
and (expires_at is null or expires_at > now())
|
||||
limit 1
|
||||
`;
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
} catch (err) {
|
||||
console.error('[SECURITY] Error checking isIpBanned:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an OpenSSH fingerprint is banned in banned_fingerprints.
|
||||
* @param {string} fingerprint
|
||||
* @returns {Promise<{ id: number, fingerprint: string, reason: string, expires: Date|null, banned_by: number|null }|null>}
|
||||
*/
|
||||
async isFingerprintBanned(fingerprint) {
|
||||
if (!fingerprint) return null;
|
||||
try {
|
||||
const rows = await db`
|
||||
select id, fingerprint, reason, expires_at as expires, banned_by
|
||||
from banned_fingerprints
|
||||
where fingerprint = ${fingerprint}
|
||||
and (expires_at is null or expires_at > now())
|
||||
limit 1
|
||||
`;
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
} catch (err) {
|
||||
console.error('[SECURITY] Error checking isFingerprintBanned:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hardware fingerprint is banned in banned_hardware_fingerprints.
|
||||
* @param {string} hwFingerprint
|
||||
* @returns {Promise<{ id: number, hw_fingerprint: string, reason: string, expires: Date|null, banned_by: number|null }|null>}
|
||||
*/
|
||||
async isHardwareBanned(hwFingerprint) {
|
||||
if (!hwFingerprint) return null;
|
||||
try {
|
||||
const rows = await db`
|
||||
select id, hw_fingerprint, reason, expires_at as expires, banned_by
|
||||
from banned_hardware_fingerprints
|
||||
where hw_fingerprint = ${hwFingerprint}
|
||||
and (expires_at is null or expires_at > now())
|
||||
limit 1
|
||||
`;
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
} catch (err) {
|
||||
console.error('[SECURITY] Error checking isHardwareBanned:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive anonymous user ban:
|
||||
* - Marks the shadow user in "user" table as banned
|
||||
* - Records fingerprint in banned_fingerprints
|
||||
* - Records hardware fingerprint in banned_hardware_fingerprints
|
||||
* - Discovers all associated IPs (from anon_activity_log, user_ips, anon_identities, comments) and inserts into banned_ips
|
||||
* - Destroys active sessions
|
||||
*/
|
||||
async banAnonymousUser({ userId = null, fingerprint = null, hwFingerprint = null, bannedBy = null, reason = 'Banned anonymous identity', expires = null, banIps = true, banHardware = true } = {}) {
|
||||
let targetFingerprint = fingerprint;
|
||||
let targetHwFingerprint = hwFingerprint;
|
||||
|
||||
if (userId && !targetFingerprint) {
|
||||
const row = (await db`select fingerprint from anon_identities where user_id = ${userId} limit 1`)[0];
|
||||
if (row) targetFingerprint = row.fingerprint;
|
||||
}
|
||||
|
||||
if (userId && !targetHwFingerprint) {
|
||||
const row = (await db`select hw_fingerprint from anon_identities where user_id = ${userId} and hw_fingerprint is not null limit 1`)[0];
|
||||
if (row) targetHwFingerprint = row.hw_fingerprint;
|
||||
}
|
||||
|
||||
if (targetFingerprint && !userId) {
|
||||
const row = (await db`select user_id from anon_identities where fingerprint = ${targetFingerprint} limit 1`)[0];
|
||||
if (row) userId = row.user_id;
|
||||
}
|
||||
|
||||
if (targetFingerprint && !targetHwFingerprint) {
|
||||
const row = (await db`select hw_fingerprint from anon_identities where fingerprint = ${targetFingerprint} and hw_fingerprint is not null limit 1`)[0];
|
||||
if (row) targetHwFingerprint = row.hw_fingerprint;
|
||||
}
|
||||
|
||||
// 1. Ban the user account
|
||||
if (userId) {
|
||||
await db`
|
||||
update "user"
|
||||
set banned = true,
|
||||
ban_reason = ${reason},
|
||||
ban_expires = ${expires}
|
||||
where id = ${userId}
|
||||
`;
|
||||
await db`delete from "user_sessions" where user_id = ${userId}`;
|
||||
}
|
||||
|
||||
// 2. Ban the fingerprint
|
||||
if (targetFingerprint) {
|
||||
await db`
|
||||
insert into banned_fingerprints (fingerprint, banned_by, reason, expires_at)
|
||||
values (${targetFingerprint}, ${bannedBy}, ${reason}, ${expires})
|
||||
on conflict (fingerprint) do update
|
||||
set reason = excluded.reason,
|
||||
expires_at = excluded.expires_at,
|
||||
banned_by = excluded.banned_by
|
||||
`;
|
||||
}
|
||||
|
||||
// 3. Ban the hardware fingerprint
|
||||
if (banHardware) {
|
||||
const associatedHws = new Set();
|
||||
if (targetHwFingerprint) associatedHws.add(targetHwFingerprint);
|
||||
|
||||
if (userId) {
|
||||
const actHws = await db`select distinct hw_fingerprint from anon_activity_log where user_id = ${userId} and hw_fingerprint is not null`;
|
||||
for (const r of actHws) if (r.hw_fingerprint) associatedHws.add(r.hw_fingerprint);
|
||||
|
||||
const identHws = await db`select hw_fingerprint from anon_identities where user_id = ${userId} and hw_fingerprint is not null`;
|
||||
for (const r of identHws) if (r.hw_fingerprint) associatedHws.add(r.hw_fingerprint);
|
||||
}
|
||||
|
||||
if (targetFingerprint) {
|
||||
const actHws = await db`select distinct hw_fingerprint from anon_activity_log where fingerprint = ${targetFingerprint} and hw_fingerprint is not null`;
|
||||
for (const r of actHws) if (r.hw_fingerprint) associatedHws.add(r.hw_fingerprint);
|
||||
}
|
||||
|
||||
for (const hw of associatedHws) {
|
||||
if (!hw) continue;
|
||||
await db`
|
||||
insert into banned_hardware_fingerprints (hw_fingerprint, banned_by, reason, expires_at)
|
||||
values (${hw}, ${bannedBy}, ${reason}, ${expires})
|
||||
on conflict (hw_fingerprint) do update
|
||||
set reason = excluded.reason,
|
||||
expires_at = excluded.expires_at,
|
||||
banned_by = excluded.banned_by
|
||||
`.catch(err => console.error('[SECURITY] Error inserting banned hardware fingerprint:', err));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cascade to associated IPs
|
||||
if (banIps) {
|
||||
const associatedIps = new Set();
|
||||
|
||||
if (userId) {
|
||||
const actIps = await db`select distinct ip from anon_activity_log where user_id = ${userId}`;
|
||||
for (const r of actIps) if (r.ip) associatedIps.add(r.ip);
|
||||
|
||||
const uIps = await db`select distinct ip from user_ips where user_id = ${userId}`;
|
||||
for (const r of uIps) if (r.ip) associatedIps.add(r.ip);
|
||||
|
||||
const cIps = await db`select distinct ip from comments where user_id = ${userId} and ip is not null`;
|
||||
for (const r of cIps) if (r.ip) associatedIps.add(r.ip);
|
||||
}
|
||||
|
||||
if (targetFingerprint) {
|
||||
const actIps = await db`select distinct ip from anon_activity_log where fingerprint = ${targetFingerprint}`;
|
||||
for (const r of actIps) if (r.ip) associatedIps.add(r.ip);
|
||||
|
||||
const identIps = await db`select created_ip, last_ip from anon_identities where fingerprint = ${targetFingerprint}`;
|
||||
for (const r of identIps) {
|
||||
if (r.created_ip) associatedIps.add(r.created_ip);
|
||||
if (r.last_ip) associatedIps.add(r.last_ip);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetHwFingerprint) {
|
||||
const actIps = await db`select distinct ip from anon_activity_log where hw_fingerprint = ${targetHwFingerprint}`;
|
||||
for (const r of actIps) if (r.ip) associatedIps.add(r.ip);
|
||||
}
|
||||
|
||||
for (const ip of associatedIps) {
|
||||
if (!ip || ip === 'unknown') continue;
|
||||
const ipHash = this.hashIP(ip);
|
||||
await db`
|
||||
insert into banned_ips (ip, ip_hash, banned_by, reason, expires_at)
|
||||
values (${ip}, ${ipHash}, ${bannedBy}, ${reason}, ${expires})
|
||||
on conflict (ip) do update
|
||||
set reason = excluded.reason,
|
||||
expires_at = excluded.expires_at,
|
||||
banned_by = excluded.banned_by,
|
||||
ip_hash = excluded.ip_hash
|
||||
`.catch(err => console.error('[SECURITY] Error inserting banned IP:', err));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, userId, fingerprint: targetFingerprint, hwFingerprint: targetHwFingerprint };
|
||||
}
|
||||
};
|
||||
|
||||
+43
-2
@@ -867,14 +867,55 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
// csrf_token is loaded from user_sessions table via the session query above
|
||||
|
||||
// Ban check
|
||||
if (req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) {
|
||||
// Ban check (Session)
|
||||
if (req.session && req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) {
|
||||
const now = new Date();
|
||||
if (req.session.ban_expires && new Date(req.session.ban_expires) < now) {
|
||||
// Ban expired, lift it
|
||||
await db`update "user" set banned = false, ban_reason = null, ban_expires = null where id = ${+req.session.id}`;
|
||||
req.session.banned = false;
|
||||
} else {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.url.pathname.startsWith('/api/')) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: false,
|
||||
banned: true,
|
||||
msg: 'YOU ARE BANNED!',
|
||||
reason: req.session.ban_reason || 'Banned',
|
||||
expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}));
|
||||
req.url.pathname = '/ban_redirect_bypass';
|
||||
return;
|
||||
}
|
||||
res.writeHead(307, {
|
||||
"Location": "/banned"
|
||||
}).end();
|
||||
req.url.pathname = '/ban_redirect_bypass';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ban check (IP and Fingerprint - applies to all requests, even without active session)
|
||||
if (!req.url.pathname.match(/^\/(banned|logout|s\/|a\/|t\/|b\/|c\/|favicon\.ico)(\/)?$/)) {
|
||||
const clientIp = security.getRealIP(req);
|
||||
const ipBan = await security.isIpBanned(clientIp);
|
||||
const fp = req.session?.fingerprint || req.session?.anon_fingerprint;
|
||||
const fpBan = fp ? await security.isFingerprintBanned(fp) : null;
|
||||
const banInfo = ipBan || fpBan;
|
||||
|
||||
if (banInfo) {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.url.pathname.startsWith('/api/')) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: false,
|
||||
banned: true,
|
||||
msg: 'YOU ARE BANNED!',
|
||||
reason: banInfo.reason || 'Banned',
|
||||
expires: banInfo.expires ? new Date(banInfo.expires).toLocaleString() : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}));
|
||||
req.url.pathname = '/ban_redirect_bypass';
|
||||
return;
|
||||
}
|
||||
res.writeHead(307, {
|
||||
"Location": "/banned"
|
||||
}).end();
|
||||
|
||||
+25
-4
@@ -11,6 +11,7 @@ import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import f0cklib from "./inc/routeinc/f0cklib.mjs";
|
||||
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
|
||||
import { addPrivateItem } from "./inc/private_items.mjs";
|
||||
import { resolveAuditIP, logAnonActivity } from "./inc/anon_auth.mjs";
|
||||
|
||||
|
||||
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
|
||||
@@ -344,6 +345,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
const filename = `yt:${videoId}`;
|
||||
|
||||
const auditIp = resolveAuditIP(req);
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
@@ -361,11 +363,20 @@ export const handleUpload = async (req, res, self) => {
|
||||
title: title,
|
||||
visibility: targetVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: targetExpiresAt
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||
expires_at: targetExpiresAt,
|
||||
uploader_ip: auditIp
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at', 'uploader_ip')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'upload',
|
||||
targetId: itemid,
|
||||
details: { type: 'youtube', videoId, slug: itemSlug }
|
||||
});
|
||||
}
|
||||
|
||||
if (targetVisibility === 2) {
|
||||
addPrivateItem(itemid, filename, req.session.user);
|
||||
}
|
||||
@@ -741,6 +752,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const auditIp = resolveAuditIP(req);
|
||||
await db`
|
||||
insert into items ${db({
|
||||
src: inputUrl || '',
|
||||
@@ -761,12 +773,21 @@ export const handleUpload = async (req, res, self) => {
|
||||
height: itemHeight,
|
||||
visibility: targetVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: targetExpiresAt
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
|
||||
expires_at: targetExpiresAt,
|
||||
uploader_ip: auditIp
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at', 'uploader_ip')}
|
||||
`;
|
||||
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'upload',
|
||||
targetId: itemid,
|
||||
details: { filename, mime: actualMime, slug: itemSlug }
|
||||
});
|
||||
}
|
||||
|
||||
if (targetVisibility === 2) {
|
||||
addPrivateItem(itemid, filename, req.session.user);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<li><a href="/admin/sessions">Sessions</a></li>
|
||||
<li><a href="/admin/tokens">Invite Tokens</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/memes">Meme Manager</a></li>
|
||||
<li><a href="/admin/koepfe">Köpfe Manager</a></li>
|
||||
|
||||
@@ -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)
|
||||
@@ -74,6 +74,7 @@
|
||||
<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_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_tagged') }}</td><td>{{ stats.tagged }}</td></tr>
|
||||
<tr><td>{{ t('ranking.stat_untagged') }}</td><td>{{ stats.untagged }}</td></tr>
|
||||
|
||||
@@ -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_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;">
|
||||
<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>
|
||||
@@ -33,14 +33,14 @@
|
||||
@else
|
||||
<img src="/a/default.png" class="profile-avatar-img" />
|
||||
@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') }}">
|
||||
<i class="fa-solid fa-pen" style="font-size: 0.8em;"></i>
|
||||
</button>
|
||||
<input type="file" id="inline-avatar-file" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
|
||||
@endif
|
||||
</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="fa-solid fa-check"></i>'; this.style.color='#51cf66'; this.style.borderColor='#51cf66'; } else { this.innerHTML='<i class="fa-solid fa-pen"></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
|
||||
</div>
|
||||
@@ -49,13 +49,13 @@
|
||||
<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">
|
||||
<span id="profile-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)⚡ @elseif(user.is_moderator)🛡 @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">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
@endif
|
||||
</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;">
|
||||
<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;">
|
||||
@@ -87,7 +87,7 @@
|
||||
<div class="profile_description" style="position: relative;">
|
||||
<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>
|
||||
@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">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
@@ -379,7 +379,7 @@
|
||||
@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 class="modal-content" style="max-width: 600px; width: 90%; text-align: left;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 15px;">Edit Description</h3>
|
||||
@@ -392,7 +392,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session && session.id === user.user_id)
|
||||
@if(session && session.id === user.user_id && !session.is_anon)
|
||||
<style>
|
||||
#live-profile-preview-box:not(.edit-mode-active) .avatar-edit-btn,
|
||||
#live-profile-preview-box:not(.edit-mode-active) #inline-banner-edit-wrapper,
|
||||
|
||||
Reference in New Issue
Block a user