This commit is contained in:
2026-09-12 21:55:40 +02:00
parent 53055ea5c6
commit 90860b9279
24 changed files with 1958 additions and 45 deletions
+74 -6
View File
@@ -86,14 +86,57 @@ export function verifySignature(sshPubkey, message, signature) {
}
}
import security from './security.mjs';
import { getHashUserIps } from './settings.mjs';
/**
* Get IP for audit/logging, hashed if hash_user_ips is enabled in config.
* @param {object} req
* @returns {string}
*/
export function resolveAuditIP(req) {
if (!req) return 'unknown';
const rawIp = security.getRealIP(req);
return getHashUserIps() ? security.hashIP(rawIp) : rawIp;
}
/**
* Log activity for an anonymous user (or session).
* @param {object} req
* @param {{ action: string, targetId?: number|string, details?: object, hwFingerprint?: string }} params
*/
export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) {
try {
const rawIp = security.getRealIP(req);
const ip = getHashUserIps() ? security.hashIP(rawIp) : rawIp;
const userId = req?.session?.id || null;
if (!userId) return;
const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null;
const hwFp = hwFingerprint || req?.session?.hw_fingerprint || null;
const numTargetId = targetId ? parseInt(targetId, 10) : null;
await db`
INSERT INTO anon_activity_log (user_id, fingerprint, hw_fingerprint, ip, action, target_id, details)
VALUES (${userId}, ${fingerprint}, ${hwFp}, ${ip}, ${action}, ${!isNaN(numTargetId) ? numTargetId : null}, ${details ? JSON.stringify(details) : null})
`;
await security.logUserIP(userId, rawIp);
} catch (err) {
console.error('[ANON_ACTIVITY_LOG] Failed to log activity:', err);
}
}
/**
* Find or create a shadow user in the database for an anonymous SSH identity.
* @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 };
}
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+92 -12
View File
@@ -2328,7 +2328,9 @@ const f0cklib = {
mime,
exclude_ids,
session_tags = '',
session_creators = ''
session_creators = '',
continuation = false,
prefer_personalized = null
} = {}) => {
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const modequery = computeBaseMode(mode, ratingsArr, session);
@@ -2420,13 +2422,30 @@ const f0cklib = {
});
}
// 2. Personalization vs Serendipity Split (60% personalized, 40% random exploration)
// 2. Personalization vs Serendipity Split
// In the sidebar suggestions:
// - 1 of the first 3 recommendations is a personalized recommendation and the other 2 are not.
// - After that, recommendations appear sporadically (~20% rate, non-consecutive with 3-5 random items between them).
const isFirstBatch = !continuation && excludeItemIds.length === 0;
let personalizedTarget;
if (maxLimit === 1) {
// For single-card replacement: 65% chance personalized, 35% chance discovery
personalizedTarget = Math.random() < 0.65 ? 1 : 0;
if (prefer_personalized === true) {
personalizedTarget = 1;
} else if (prefer_personalized === false) {
personalizedTarget = 0;
} else {
personalizedTarget = Math.random() < 0.20 ? 1 : 0;
}
} else if (isFirstBatch) {
// 1 in top 3, plus sporadic recommendations in the remaining slots
const topRec = maxLimit >= 1 ? 1 : 0;
const remainingSlots = Math.max(0, maxLimit - 3);
const sporadicRecs = Math.round(remainingSlots * 0.20);
personalizedTarget = topRec + sporadicRecs;
} else {
personalizedTarget = Math.round(maxLimit * 0.60);
// Continuation batch: sporadic recommendations throughout
personalizedTarget = Math.max(1, Math.round(maxLimit * 0.20));
}
const targetTagIds = targetTagEntries.map(([tid]) => tid);
@@ -2537,8 +2556,9 @@ const f0cklib = {
let randomItems = [];
if (neededRandom > 0) {
const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)];
// Fetch extra buffer of random items to ensure ample spacing
randomItems = await f0cklib.getRandomRecommendations({
limit: neededRandom,
limit: Math.max(neededRandom + 3, maxLimit),
mode,
ratings,
session,
@@ -2550,14 +2570,74 @@ const f0cklib = {
});
}
// 4. Combine & Interweave with Fisher-Yates Shuffle
const combined = [...personalizedItems, ...randomItems];
for (let i = combined.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
// 4. Combine & Interweave
if (maxLimit === 1) {
return personalizedItems.length > 0 ? personalizedItems.slice(0, 1) : randomItems.slice(0, 1);
}
return combined.slice(0, maxLimit);
if (personalizedItems.length === 0) {
return randomItems.slice(0, maxLimit);
}
if (randomItems.length === 0) {
return personalizedItems.slice(0, maxLimit);
}
const result = [];
if (isFirstBatch) {
// First 3 items: exactly 1 is a recommendation, and the other 2 are not
const topCount = Math.min(3, maxLimit);
const topSlots = new Array(topCount);
const recSlot = Math.floor(Math.random() * topCount);
topSlots[recSlot] = personalizedItems.shift();
for (let s = 0; s < topCount; s++) {
if (s !== recSlot) {
topSlots[s] = randomItems.length > 0 ? randomItems.shift() : personalizedItems.shift();
}
}
for (let s = 0; s < topCount; s++) {
if (topSlots[s]) result.push(topSlots[s]);
}
// Remaining slots: recommendations come sporadically (separated by 3 to 5 non-recommendations)
let gapSinceRec = topCount - 1 - recSlot;
let targetGap = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5 random items
while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) {
if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
targetGap = Math.floor(Math.random() * 3) + 3;
} else if (randomItems.length > 0) {
result.push(randomItems.shift());
gapSinceRec++;
} else if (personalizedItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
}
}
} else {
// Continuation batch: recommendations come sporadically throughout
let gapSinceRec = Math.floor(Math.random() * 2) + 1;
let targetGap = Math.floor(Math.random() * 3) + 3;
while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) {
if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
targetGap = Math.floor(Math.random() * 3) + 3;
} else if (randomItems.length > 0) {
result.push(randomItems.shift());
gapSinceRec++;
} else if (personalizedItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
}
}
}
return result.slice(0, maxLimit);
},
getTagFeedItems: async ({
+209
View File
@@ -323,6 +323,196 @@ export default (router, tpl) => {
});
});
router.get(/^\/admin\/bans(\/)?$/, lib.modAuth, async (req, res) => {
const bannedFingerprints = await db`
select bf.*,
u.login as anon_login, u.user as anon_user,
admin.user as banned_by_user
from banned_fingerprints bf
left join anon_identities ai on ai.fingerprint = bf.fingerprint
left join "user" u on u.id = ai.user_id
left join "user" admin on admin.id = bf.banned_by
order by bf.created_at desc
`;
const bannedHardware = await db`
select bh.*,
admin.user as banned_by_user
from banned_hardware_fingerprints bh
left join "user" admin on admin.id = bh.banned_by
order by bh.created_at desc
`;
const bannedIps = await db`
select bi.*,
admin.user as banned_by_user
from banned_ips bi
left join "user" admin on admin.id = bi.banned_by
order by bi.created_at desc
`;
const anonIdentities = await db`
select ai.*,
u.id as user_id, u.login, u.user, u.banned as is_banned, u.ban_reason,
bf.id as is_fp_banned,
bh.id as is_hw_banned
from anon_identities ai
left join "user" u on u.id = ai.user_id
left join banned_fingerprints bf on bf.fingerprint = ai.fingerprint
left join banned_hardware_fingerprints bh on bh.hw_fingerprint = ai.hw_fingerprint
order by ai.last_seen desc
limit 100
`;
const recentActivity = await db`
select al.*,
u.login, u.user
from anon_activity_log al
left join "user" u on u.id = al.user_id
order by al.created_at desc
limit 100
`;
res.reply({
body: tpl.render("admin/bans", {
session: req.session,
csrf_token: req.session ? req.session.csrf_token : '',
bannedFingerprints,
bannedHardware,
bannedIps,
anonIdentities,
recentActivity,
tmp: null
}, req)
});
});
router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { fingerprint, reason, duration, ban_ips, user_id } = req.post || {};
if (!fingerprint) throw new Error('Missing fingerprint');
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
const result = await security.banAnonymousUser({
userId: user_id ? +user_id : null,
fingerprint,
bannedBy: req.session.id,
reason: reason || 'Banned by moderator',
expires,
banIps: ban_ips !== false
});
await audit.log(req.session.id, 'ban_fingerprint', 'fingerprint', null, { fingerprint, reason, duration });
return res.json({ success: true, result });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/unban\/?$/, lib.modAuth, async (req, res) => {
try {
const { fingerprint } = req.post || {};
if (!fingerprint) throw new Error('Missing fingerprint');
await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${fingerprint}`;
// Also unban any shadow user associated with this fingerprint
const ident = await db`SELECT user_id FROM anon_identities WHERE fingerprint = ${fingerprint}`;
if (ident.length > 0) {
await db`UPDATE "user" SET banned = false, ban_reason = null, ban_expires = null WHERE id = ${ident[0].user_id}`;
}
await audit.log(req.session.id, 'unban_fingerprint', 'fingerprint', null, { fingerprint });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/ip\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { ip, reason, duration } = req.post || {};
if (!ip) throw new Error('Missing IP address');
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
const ipHash = security.hashIP(ip);
await db`
INSERT INTO banned_ips (ip, ip_hash, banned_by, reason, expires_at)
VALUES (${ip}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires})
ON CONFLICT (ip) DO UPDATE
SET reason = EXCLUDED.reason,
expires_at = EXCLUDED.expires_at,
banned_by = EXCLUDED.banned_by,
ip_hash = EXCLUDED.ip_hash
`;
await audit.log(req.session.id, 'ban_ip', 'ip', null, { ip, reason, duration });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/ip\/unban\/?$/, lib.modAuth, async (req, res) => {
try {
const { ip } = req.post || {};
if (!ip) throw new Error('Missing IP');
await db`DELETE FROM banned_ips WHERE ip = ${ip} OR ip_hash = ${ip}`;
await audit.log(req.session.id, 'unban_ip', 'ip', null, { ip });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/hardware\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { hw_fingerprint, reason, duration, ban_ips, user_id } = req.post || {};
if (!hw_fingerprint) throw new Error('Missing hardware fingerprint');
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
const result = await security.banAnonymousUser({
userId: user_id ? +user_id : null,
hwFingerprint: hw_fingerprint,
bannedBy: req.session.id,
reason: reason || 'Banned by moderator',
expires,
banIps: ban_ips !== false,
banHardware: true
});
await audit.log(req.session.id, 'ban_hardware', 'hardware', null, { hw_fingerprint, reason, duration });
return res.json({ success: true, result });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/hardware\/unban\/?$/, lib.modAuth, async (req, res) => {
try {
const { hw_fingerprint } = req.post || {};
if (!hw_fingerprint) throw new Error('Missing hardware fingerprint');
await db`DELETE FROM banned_hardware_fingerprints WHERE hw_fingerprint = ${hw_fingerprint}`;
await audit.log(req.session.id, 'unban_hardware', 'hardware', null, { hw_fingerprint });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
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);
+130 -3
View File
@@ -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
});
+21 -1
View File
@@ -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);
+22
View File
@@ -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);
+32 -3
View File
@@ -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)
+28 -2
View File
@@ -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;
+15
View File
@@ -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,
+205
View File
@@ -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 };
}
};