gfsd
This commit is contained in:
+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.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 ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user