This commit is contained in:
2026-09-13 04:05:59 +02:00
parent 90860b9279
commit 340c825019
48 changed files with 2727 additions and 532 deletions
+7
View File
@@ -5,6 +5,7 @@ import cfg from "./inc/config.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import { collectBody } from "./inc/multipart.mjs";
import { canAnonDo, isAnonSession } from "./inc/settings.mjs";
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
@@ -141,6 +142,12 @@ export const handleCommentUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('comment') || !canAnonDo('comment_attachments')) {
return sendJson(res, { success: false, msg: 'Anonymous comment file uploads are disabled' }, 403);
}
}
// Check if comment file upload is enabled
if (!cfg.websrv.allow_fileupload_comments) {
return sendJson(res, { success: false, msg: 'Comment file uploads are disabled' }, 403);
+7 -2
View File
@@ -4,7 +4,7 @@ import db from "./sql.mjs";
import cfg from "./config.mjs";
import { createI18n } from "./i18n.mjs";
import { getEnableAnonymousAccess } from "./settings.mjs";
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo } from "./settings.mjs";
@@ -285,7 +285,8 @@ export default new class {
return false;
};
async getTags(itemid, session = null) {
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
const isAnonymized = isAnonymizeSession(session);
const hasSession = !isAnonymized && !!(session && !session.is_anon && (typeof session === 'object' ? (session.id || session.user) : session));
const tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "tags_assign"
@@ -296,6 +297,8 @@ export default new class {
`;
let hasRating = false;
const cleanTags = [];
const excludedTagIds = (session && Array.isArray(session.excluded_tags)) ? session.excluded_tags : [];
const canExclude = (session && !session.is_anon) ? true : canAnonDo('exclude_tags');
for (let t = 0; t < tags.length; t++) {
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
if (isRating) {
@@ -303,6 +306,8 @@ export default new class {
hasRating = true;
}
tags[t].badge = this.getBadge(tags[t]);
tags[t].is_excluded = !isRating && excludedTagIds.includes(tags[t].id);
tags[t].can_exclude = canExclude;
if (!hasSession) {
delete tags[t].user;
delete tags[t].display_name;
+6
View File
@@ -270,6 +270,12 @@
"start_export": "Export generieren (ZIP)"
},
"filter": {
"excluded_tags": "Ausgeschlossene Tags",
"exclude_tag": "Tag ausschließen",
"unexclude_tag": "Ausgeschlossen (klicken zum Aufheben)",
"no_tags_excluded": "Noch keine Tags ausgeschlossen",
"tag_excluded_msg": "Tag '{tag}' zu ausgeschlossenen Tags hinzugefügt",
"tag_unexcluded_msg": "Tag '{tag}' aus ausgeschlossenen Tags entfernt",
"tag_placeholder": "Tag ausschließen",
"random_mode": "RAND",
"min_xd_score": "Min. xD-Score",
+6
View File
@@ -270,6 +270,12 @@
"start_export": "Generate Export (ZIP)"
},
"filter": {
"excluded_tags": "Excluded Tags",
"exclude_tag": "Exclude tag",
"unexclude_tag": "Excluded (click to unexclude)",
"no_tags_excluded": "No tags excluded yet",
"tag_excluded_msg": "Tag '{tag}' added to excluded tags",
"tag_unexcluded_msg": "Tag '{tag}' removed from excluded tags",
"tag_placeholder": "Tag to exclude",
"random_mode": "RAND",
"min_xd_score": "Min xD Score",
+6
View File
@@ -268,6 +268,12 @@
"start_export": "Export genereren (ZIP)"
},
"filter": {
"excluded_tags": "Uitgesloten Tags",
"exclude_tag": "Tag uitsluiten",
"unexclude_tag": "Uitgesloten (klik om te herstellen)",
"no_tags_excluded": "Nog geen tags uitgesloten",
"tag_excluded_msg": "Tag '{tag}' toegevoegd aan uitgesloten tags",
"tag_unexcluded_msg": "Tag '{tag}' verwijderd uit uitgesloten tags",
"tag_placeholder": "Tag om uit te sluiten",
"random_mode": "WILLEKEURIG",
"min_xd_score": "Min xD-score",
+6
View File
@@ -266,6 +266,12 @@
"start_export": "Paket schnüren"
},
"filter": {
"excluded_tags": "Ausgeschlossene Etiketten",
"exclude_tag": "Etikett ausschließen",
"unexclude_tag": "Ausgeschlossen (klicken zum Wiederherstellen)",
"no_tags_excluded": "Noch keine Etiketten ausgeschlossen",
"tag_excluded_msg": "Etikett '{tag}' zu ausgeschlossenen Etiketten hinzugefügt",
"tag_unexcluded_msg": "Etikett '{tag}' aus ausgeschlossenen Etiketten entfernt",
"tag_placeholder": "Auszuschließendes Etikett",
"random_mode": "ZUFA",
"min_xd_score": "Min. xD-Punktestand",
+133 -52
View File
@@ -1,7 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession } from "../settings.mjs";
import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs";
import fs from "fs";
@@ -15,7 +15,7 @@ const getGlobalfilter = () => {
};
const computeBaseMode = (mode, ratings, session) => {
const effMode = Number(mode ?? 0);
let effMode = Number(mode ?? 0);
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
// For guest sessions, sanitize ratingsArr to only allow permitted ratings
@@ -28,6 +28,32 @@ const computeBaseMode = (mode, ratings, session) => {
if (safeRatingsArr.length === 0) {
return "1 = 0";
}
} else if (isAnonSession(session)) {
const allowedModes = getAnonAllowedModes();
const canFilter = canAnonDo('filter');
if (!canFilter) {
safeRatingsArr = null;
effMode = 0;
} else if (safeRatingsArr) {
safeRatingsArr = safeRatingsArr.filter(r => allowedModes.includes(r));
if (safeRatingsArr.length === 0) {
return "1 = 0";
}
}
const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
const currentModeName = modeNames[effMode] || 'sfw';
if (effMode === 3) {
if (allowedModes.length < 5) {
safeRatingsArr = [...allowedModes];
}
} else if (!allowedModes.includes(currentModeName)) {
const fallbackModeName = allowedModes[0] || 'sfw';
const fallbackModeIdx = modeNames.indexOf(fallbackModeName);
effMode = fallbackModeIdx >= 0 ? fallbackModeIdx : 0;
}
}
let baseMode;
@@ -67,6 +93,21 @@ const computeBaseMode = (mode, ratings, session) => {
} else if (effMode === 4) {
baseMode = "1 = 0";
}
} else if (isAnonSession(session)) {
const allowedModes = getAnonAllowedModes();
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (!allowedModes.includes('nsfl')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`;
}
if (!allowedModes.includes('nsfw')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 2)`;
}
if (!allowedModes.includes('untagged')) {
baseMode = `(${baseMode}) and exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
}
if (!allowedModes.includes('sfw')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`;
}
}
return baseMode;
};
@@ -87,6 +128,36 @@ const resolveNumericItemId = async (itemIdOrSlug) => {
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
const resolveMimeSQL = (rawMime, session, itemAlias = 'items') => {
let mimeParts = (rawMime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
if (isAnonSession(session)) {
const allowedMimes = getAnonAllowedMimes();
const canFilter = canAnonDo('filter');
if (!canFilter || allowedMimes.length === 1) {
mimeParts = allowedMimes.length < 5 ? [...allowedMimes] : [];
} else {
if (mimeParts.length > 0) {
mimeParts = mimeParts.filter(m => allowedMimes.includes(m));
if (mimeParts.length === 0) {
mimeParts = allowedMimes.length < 5 ? [...allowedMimes] : [];
}
} else if (allowedMimes.length < 5) {
mimeParts = [...allowedMimes];
}
}
}
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? (itemAlias === 'i' ? flashMimes.map(fm => db`i.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) : flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`))
: db`false`)
: (m === 'pdf' ? (itemAlias === 'i' ? db`i.mime = 'application/pdf'` : db`items.mime = 'application/pdf'`) : (itemAlias === 'i' ? db`i.mime ilike ${m + '/%'}` : db`items.mime ilike ${m + '/%'}`))).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
return { mimeParts, mimeSQL };
};
// ── Count cache ─────────────────────────────────────────────────────────────
// The COUNT(DISTINCT items.id) in getf0cks is expensive (full filtered scan).
// Cache it per unique filter combination for 90 seconds so that navigating
@@ -300,16 +371,7 @@ const buildFeedFilters = async ({
if (uhData.length) userHallObj = uhData[0];
}
const mime = rawMime ?? null;
// Support multiple MIME types (comma separated)
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = session && exclude ? (exclude || []) : [];
const newerThan = newer ? parseInt(newer) : null;
@@ -853,14 +915,7 @@ const f0cklib = {
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = exclude || [];
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
@@ -1404,14 +1459,7 @@ const f0cklib = {
}
// Support multiple MIME types (comma separated)
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = session && exclude ? (exclude || []) : [];
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
@@ -2113,14 +2161,7 @@ const f0cklib = {
? db`AND items.id != ALL(${excludeItemIds}::int[])`
: db``;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
let rows;
if (mimeParts.length > 0) {
@@ -2298,6 +2339,59 @@ const f0cklib = {
}
},
updateUserTagAffinity: async ({ user_id, tag, scoreDelta = 2.0 }) => {
if (!user_id || !tag || !scoreDelta) return;
try {
const rawList = typeof tag === 'string'
? tag.split(',')
: (Array.isArray(tag) ? tag : [tag]);
const tagsList = rawList
.map(t => typeof t === 'string' ? t.trim().toLowerCase() : '')
.filter(t => t && !t.startsWith('title:') && !t.startsWith('src:'))
.slice(0, 10);
if (tagsList.length === 0) return;
const slugList = tagsList.map(t => lib.slugify(t)).filter(Boolean);
const tagRows = await db`
SELECT DISTINCT id FROM tags
WHERE LOWER(tag) = ANY(${tagsList}::text[])
OR (normalized != '' AND normalized = ANY(${slugList}::text[]))
`;
if (tagRows.length === 0) return;
const tagIds = tagRows.map(r => r.id);
// Guard: Only increment score and interaction_count if last_interacted was more than 10s ago,
// avoiding duplicate score inflation from rapid page refreshes or dual beacon/page loads.
await db`
INSERT INTO user_tag_affinity (user_id, tag_id, score, interaction_count, last_interacted)
SELECT
${user_id},
unnest(${tagIds}::int[]),
${scoreDelta},
1,
now()
ON CONFLICT (user_id, tag_id) DO UPDATE SET
score = CASE
WHEN user_tag_affinity.last_interacted < now() - interval '10 seconds'
THEN GREATEST(-10.0, LEAST(1000.0, user_tag_affinity.score + EXCLUDED.score))
ELSE user_tag_affinity.score
END,
interaction_count = CASE
WHEN user_tag_affinity.last_interacted < now() - interval '10 seconds'
THEN user_tag_affinity.interaction_count + 1
ELSE user_tag_affinity.interaction_count
END,
last_interacted = now()
`;
} catch (err) {
console.error("[AFFINITY] Failed to update user tag affinity from search:", err);
}
},
decayUserAffinities: async () => {
try {
await db`
@@ -2455,14 +2549,7 @@ const f0cklib = {
? db`AND items.id != ALL(${excludeItemIds}::int[])`
: db``;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
let personalizedItems = [];
if (personalizedTarget > 0 && targetTagIds.length > 0) {
@@ -2671,14 +2758,7 @@ const f0cklib = {
? db`AND (COALESCE(items.visibility, 0) = 0 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))`
: db`AND COALESCE(items.visibility, 0) = 0`);
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
const isStrict = !!strict || (tag && tag.includes(','));
@@ -2835,6 +2915,7 @@ const f0cklib = {
processEmbeds,
computeXdScore,
xdScoreMeta,
resolveMimeSQL,
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
clearCountCache: () => countCache.clear()
};
+200 -75
View File
@@ -77,7 +77,15 @@ export default (router, tpl) => {
} else {
const reason = user[0].ban_reason || 'none';
const expires = user[0].ban_expires ? new Date(user[0].ban_expires).toISOString().replace('T', ' ').substring(0, 16) : 'never';
return fail(`You are banned! reason: ${reason} expire: ${expires}`);
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: false,
banned: true,
msg: `You are banned! reason: ${reason} expire: ${expires}`,
redirect: '/banned'
}));
}
return res.writeHead(302, { Location: '/banned' }).end();
}
}
@@ -452,6 +460,14 @@ export default (router, tpl) => {
await audit.log(req.session.id, 'ban_ip', 'ip', null, { ip, reason, duration });
// Broadcast ban event via SSE
await db.notify('bans', JSON.stringify({
ip,
ipHash,
reason: (reason || 'Banned by moderator').substring(0, 300),
expires
})).catch(() => {});
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
@@ -682,6 +698,16 @@ export default (router, tpl) => {
expires,
banIps: true
});
} else {
// Broadcast ban to registered user's active SSE sessions
const userIps = await db`SELECT distinct ip FROM user_ips WHERE user_id = ${+user_id}`;
const ips = userIps.map(r => r.ip).filter(Boolean);
await db.notify('bans', JSON.stringify({
userId: +user_id,
reason: (reason || 'Violation of community rules').substring(0, 300),
expires,
ips
})).catch(() => {});
}
// Log it in audit
@@ -1109,87 +1135,179 @@ export default (router, tpl) => {
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
const limit = 50;
const offset = (page - 1) * limit;
const rawStatus = (req.url.qs?.status || req.url.qs?.filter || '').toLowerCase().trim();
const rawRole = (req.url.qs?.role || '').toLowerCase().trim();
const users = await db`
WITH filtered_users AS (
SELECT
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
),
ghost_users AS (
SELECT
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
const onlyLegacy = req.url.qs?.legacy === '1' || req.url.qs?.legacy === 'true' ||
req.url.qs?.legacy_only === '1' || req.url.qs?.legacy_only === 'true' ||
req.url.qs?.only_legacy === '1' || req.url.qs?.only_legacy === 'true' ||
rawStatus === 'legacy';
const status = onlyLegacy ? '' : rawStatus;
const role = onlyLegacy ? '' : rawRole;
let users;
let total;
if (onlyLegacy) {
users = await db`
WITH ghost_users AS (
SELECT
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
FROM items i
WHERE i.username IS NOT NULL AND i.username != ''
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
GROUP BY i.username
),
paginated_users AS (
SELECT * FROM ghost_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
SELECT
pu.*,
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
COALESCE(ic.upload_count, 0) as upload_count,
0::bigint as comment_count,
0::bigint as failed_attempts
FROM paginated_users pu
LEFT JOIN LATERAL (
SELECT COUNT(*) as upload_count
FROM items
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
) ic ON true
`;
const totalCountGhost = await db`
SELECT COUNT(DISTINCT i.username) as c
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
WHERE i.username IS NOT NULL AND i.username != ''
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
GROUP BY i.username
),
all_users AS (
SELECT * FROM filtered_users
UNION ALL
SELECT * FROM ghost_users
),
paginated_users AS (
SELECT * FROM all_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
SELECT
pu.*,
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
COALESCE(ic.upload_count, 0) as upload_count,
COALESCE(cc.comment_count, 0) as comment_count,
COALESCE(la.failed_attempts, 0) as failed_attempts
FROM paginated_users pu
LEFT JOIN LATERAL (
SELECT COUNT(*) as upload_count
FROM items
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
) ic ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) as comment_count
FROM comments
WHERE user_id = pu.id AND is_deleted = false
) cc ON pu.id IS NOT NULL
LEFT JOIN LATERAL (
SELECT COUNT(*) as failed_attempts
FROM login_attempts
WHERE username = pu.login
AND success = false
AND type = 'login'
AND attempted_at > now() - interval '10 hours'
) la ON true
`;
`;
total = parseInt(totalCountGhost[0].c);
} else {
let qCond = null;
if (q) {
if (exactMatch) {
qCond = db`(lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q}))`;
} else {
const pattern = '%' + lib.escapeLike(q) + '%';
qCond = db`(u.login ILIKE ${pattern} OR u.user ILIKE ${pattern} OR u.email ILIKE ${pattern})`;
}
}
const totalCountActual = await db`
SELECT COUNT(*) as c FROM "user" u
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
`;
const totalCountGhost = await db`
SELECT COUNT(DISTINCT i.username) as c
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
`;
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
let statusCond = null;
if (status === 'banned') {
statusCond = db`u.banned = true`;
} else if (status === 'active') {
statusCond = db`(u.activated = true AND u.banned = false)`;
} else if (status === 'pending') {
statusCond = db`(u.activated = false AND u.banned = false)`;
}
let roleCond = null;
if (role === 'staff' || status === 'staff') {
roleCond = db`(u.admin = true OR u.is_moderator = true)`;
} else if (role === 'admin') {
roleCond = db`u.admin = true`;
} else if (role === 'mod') {
roleCond = db`(u.is_moderator = true AND u.admin = false)`;
} else if (role === 'user') {
roleCond = db`(u.admin = false AND u.is_moderator = false)`;
}
users = await db`
WITH filtered_users AS (
SELECT
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE true
${qCond ? db`AND ${qCond}` : db``}
${statusCond ? db`AND ${statusCond}` : db``}
${roleCond ? db`AND ${roleCond}` : db``}
),
paginated_users AS (
SELECT * FROM filtered_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
SELECT
pu.*,
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
COALESCE(ic.upload_count, 0) as upload_count,
COALESCE(cc.comment_count, 0) as comment_count,
COALESCE(la.failed_attempts, 0) as failed_attempts
FROM paginated_users pu
LEFT JOIN LATERAL (
SELECT COUNT(*) as upload_count
FROM items
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
) ic ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) as comment_count
FROM comments
WHERE user_id = pu.id AND is_deleted = false
) cc ON pu.id IS NOT NULL
LEFT JOIN LATERAL (
SELECT COUNT(*) as failed_attempts
FROM login_attempts
WHERE username = pu.login
AND success = false
AND type = 'login'
AND attempted_at > now() - interval '10 hours'
) la ON true
`;
const totalCountActual = await db`
SELECT COUNT(*) as c FROM "user" u
WHERE true
${qCond ? db`AND ${qCond}` : db``}
${statusCond ? db`AND ${statusCond}` : db``}
${roleCond ? db`AND ${roleCond}` : db``}
`;
total = parseInt(totalCountActual[0].c);
}
let totalLabel = 'registered members';
let emptyMsg = 'No users matched your search.';
if (onlyLegacy) {
totalLabel = 'legacy accounts';
emptyMsg = 'No legacy users matched your search.';
} else if (status === 'banned') {
totalLabel = 'banned members';
emptyMsg = 'No banned users found.';
} else if (status === 'pending') {
totalLabel = 'pending members';
emptyMsg = 'No pending users found.';
} else if (status === 'active') {
totalLabel = 'active members';
emptyMsg = 'No active users found.';
} else if (role === 'staff' || status === 'staff') {
totalLabel = 'staff members';
emptyMsg = 'No staff members found.';
} else if (role === 'admin') {
totalLabel = 'admin members';
emptyMsg = 'No admin users found.';
} else if (role === 'mod') {
totalLabel = 'moderator members';
emptyMsg = 'No moderator users found.';
} else if (role === 'user') {
totalLabel = 'regular users';
emptyMsg = 'No regular users found.';
}
const data = {
session: req.session,
@@ -1198,6 +1316,11 @@ export default (router, tpl) => {
page,
total,
hasMore: users.length === limit,
onlyLegacy,
status,
role,
totalLabel,
emptyMsg,
totals: await lib.countf0cks(),
log_user_ips: getLogUserIps(),
tmp: null
@@ -1205,6 +1328,8 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
res.setHeader('X-Total-Count', total.toString());
res.setHeader('X-Total-Label', totalLabel);
res.setHeader('X-Empty-Msg', emptyMsg);
res.setHeader('X-Has-More', (users.length === limit).toString());
return res.reply({
body: tpl.render("admin/users_list", data, req)
+3 -2
View File
@@ -2,6 +2,7 @@ import f0cklib from "../routeinc/f0cklib.mjs";
import url from "url";
import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs";
import { isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
@@ -140,8 +141,8 @@ export default (router, tpl) => {
if (data.item) {
const session = data.session;
const item = data.item;
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
+71 -36
View File
@@ -12,6 +12,24 @@ export default router => {
* POST /api/v2/anon/session
* Authenticate via OpenSSH Ed25519 signature and establish an anonymous session.
*/
const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => {
if (!sourceReason) return prefix;
let clean = sourceReason;
while (/^Cascade ban from (device|hardware ID|key) \((.*)\)$/.test(clean)) {
clean = clean.replace(/^Cascade ban from (device|hardware ID|key) \((.*)\)$/, '$2');
}
return `${prefix} (${clean || 'Violation of community rules'})`;
};
const setBanCookie = (res, reason, expires) => {
const payload = encodeURIComponent(JSON.stringify({
banned: true,
reason: reason || 'Banned',
expires: expires ? new Date(expires).toISOString() : null
}));
res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`);
};
group.post(/\/session$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
@@ -21,6 +39,7 @@ export default router => {
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({
success: false,
banned: true,
@@ -64,15 +83,19 @@ export default router => {
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
});
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, activeTombstoneBan.reason || 'Device is banned', activeTombstoneBan.expires);
return res.json({
success: false,
banned: true,
@@ -90,15 +113,19 @@ export default router => {
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
});
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, hwBan.reason || 'Hardware ID is banned', hwBan.expires);
return res.json({
success: false,
banned: true,
@@ -116,16 +143,20 @@ export default router => {
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
});
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
if (!alreadyHwBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint,
bannedBy: fpBan.banned_by,
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
expires: fpBan.expires,
banIps: true,
banHardware: true
});
}
}
setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires);
return res.json({
success: false,
banned: true,
@@ -144,15 +175,19 @@ export default router => {
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
});
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
userId,
fingerprint: parsed.fingerprint,
hwFingerprint,
reason: u.ban_reason || 'Banned',
expires: u.ban_expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({
success: false,
banned: true,
+100 -13
View File
@@ -2,7 +2,7 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import { getEnableItemSlugs, canAnonDo, isAnonSession, isAnonymizeSession } from '../../settings.mjs';
import queue from '../../queue.mjs';
import search from '../../routeinc/search.mjs';
import path from "path";
@@ -583,7 +583,8 @@ export default router => {
const userHall = req.url.qs.userHall || null;
const userHallOwner= req.url.qs.userHallOwner|| null;
const user = req.url.qs.user || null;
const mime = req.url.qs.mime || null;
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const mime = (typeof req.url.qs.mime !== 'undefined') ? (req.url.qs.mime || null) : (cookieMime || null);
const isFav = req.url.qs.fav === 'true';
const isStrict = req.url.qs.strict === '1';
const mode = req.mode ?? 0;
@@ -598,7 +599,7 @@ export default router => {
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
@@ -616,7 +617,10 @@ export default router => {
const user = req.url.qs.user || pathUser || null;
const pathMime = allowedMimes.includes(pathParts[4]) ? pathParts[4] : "";
const mime = req.url.qs.mime || pathMime || (req.cookies.mime || null);
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const mime = (typeof req.url.qs?.mime !== 'undefined')
? (req.url.qs.mime || null)
: (cookieMime || (pathMime || null));
const tag = req.url.qs.tag || null;
const hall = req.url.qs.hall || null;
@@ -639,7 +643,7 @@ export default router => {
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
@@ -667,7 +671,7 @@ export default router => {
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
@@ -743,7 +747,7 @@ export default router => {
limit,
mode,
ratings: ratingsArr,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin,
@@ -755,6 +759,7 @@ export default router => {
prefer_personalized: preferPersonalized
});
const isAnonUser = isAnonymizeSession(req.session);
res.json({
success: true,
items: items.map(item => ({
@@ -764,9 +769,9 @@ export default router => {
dest: item.dest,
mime: item.mime,
stamp: item.stamp,
username: item.username,
display_name: item.display_name,
username_color: item.username_color,
username: isAnonUser ? 'anonymous' : item.username,
display_name: isAnonUser ? null : item.display_name,
username_color: isAnonUser ? null : item.username_color,
has_coverart: item.has_coverart,
rating_class: item.rating_class,
xd_score: item.xd_score,
@@ -833,6 +838,37 @@ export default router => {
}
});
// Track tag search interest signal
group.post(/\/track\/search$/, async (req, res) => {
try {
let payload = req.post || {};
if (!payload || Object.keys(payload).length === 0) {
try {
const body = await collectBody(req);
if (body && body.length > 0) payload = JSON.parse(body.toString());
} catch (_) {}
}
const tag = (payload.tag || payload.q || req.url.qs?.tag || req.url.qs?.q);
if (!tag || typeof tag !== 'string' || !tag.trim()) {
return res.json({ success: false, error: "Invalid tag parameter" }, 400);
}
if (req.session?.id) {
f0cklib.updateUserTagAffinity({
user_id: req.session.id,
tag: tag.trim(),
scoreDelta: 2.0
}).catch(err => console.error("[TRACK] Search affinity update failed:", err));
}
return res.json({ success: true });
} catch (err) {
console.error("[TRACK] Search tracking error:", err);
return res.json({ success: false, error: "Tracking failed" }, 500);
}
});
group.get(/\/tag-feed$/, async (req, res) => {
try {
const tag = req.url.qs?.tag ? String(req.url.qs.tag).trim() : null;
@@ -866,7 +902,7 @@ export default router => {
focus_id: focusId,
mode,
ratings: ratingsArr,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin,
@@ -875,6 +911,17 @@ export default router => {
});
const effectiveOffset = result.offset ?? offset;
const isAnonUser = isAnonymizeSession(req.session);
const outItems = isAnonUser
? result.items.map(it => ({
...it,
username: 'anonymous',
display_name: 'anonymous',
username_color: null,
avatar: '/a/default.png',
avatar_file: null
}))
: result.items;
return res.json({
success: true,
@@ -883,7 +930,7 @@ export default router => {
total: result.total,
offset: effectiveOffset,
limit: result.limit ?? limit,
items: result.items,
items: outItems,
hasMore: (effectiveOffset + result.items.length) < result.total
});
} catch (err) {
@@ -899,6 +946,14 @@ export default router => {
group.get(/\/orakel\/user$/, async (req, res) => {
try {
if (isAnonymizeSession(req.session)) {
return res.json({
success: true,
username: 'anonymous',
display_name: 'Anonymous',
id: 0
});
}
const now = ~~(Date.now() / 1000);
const sevenDaysAgo = now - 604800; // 7 days in seconds
@@ -1088,6 +1143,13 @@ export default router => {
prev: prev[0]?.id ?? null
};
if (isAnonymizeSession(req.session)) {
rows.username = 'anonymous';
rows.display_name = null;
rows.user = 'anonymous';
rows.src = null;
}
return res.json({
success: true,
rows
@@ -1095,6 +1157,9 @@ export default router => {
});
group.get(/\/user\/(?<user>[^\/]+)(\/(?<eps>\d+))?$/, async (req, res) => {
if (isAnonymizeSession(req.session)) {
return res.json({ success: false, msg: 'access denied' });
}
const user = req.params.user;
const eps = +req.params.eps || 50;
@@ -1147,6 +1212,9 @@ export default router => {
group.get(/\/users\/suggest$/, async (req, res) => {
if (isAnonymizeSession(req.session)) {
return res.json({ success: true, suggestions: [] });
}
const searchString = req.url.qs.q;
if (!searchString || searchString.length < 1) {
return res.json({ success: false, suggestions: [] });
@@ -1357,6 +1425,9 @@ export default router => {
});
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('favorite')) {
return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403);
}
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
if (rawPostid === undefined || rawPostid === null) {
return res.json({ success: false, msg: 'Missing postid' }, 400);
@@ -1431,6 +1502,9 @@ export default router => {
});
group.post(/\/favorites\/import$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('favorite')) {
return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403);
}
try {
const rawIds = req.post?.ids ?? req.body?.ids;
let ids = [];
@@ -1704,7 +1778,20 @@ export default router => {
});
});
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.registeredUser, async (req, res) => {
const ratingAuth = (req, res, next) => {
if (!req.session) {
return res.json({ success: false, msg: 'Unauthorized' }, 401);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('rate_item')) {
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
}
return next();
}
return lib.registeredUser(req, res, next);
};
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, ratingAuth, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
+22 -6
View File
@@ -4,6 +4,7 @@ import cfg from '../../config.mjs';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { canAnonDo, isAnonSession } from '../../settings.mjs';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -74,6 +75,9 @@ export default router => {
});
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
@@ -83,10 +87,16 @@ export default router => {
});
group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagname = req.post?.tagname || req.body?.tagname;
if (!tagname) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tagId = req.post?.tag_id || req.body?.tag_id;
if (!tagname && !tagId) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tag = (await db`select id, tag, normalized from tags where normalized = slugify(${tagname})`)[0];
const tag = tagId
? (await db`select id, tag, normalized from tags where id = ${+tagId}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagname}) or tag = ${tagname}`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
@@ -110,12 +120,18 @@ export default router => {
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
return res.json({ success: true, tags, tag }, 200);
});
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
const tagname = decodeURIComponent(req.params.tag);
const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0];
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagParam = decodeURIComponent(req.params.tag);
const isNum = /^\d+$/.test(tagParam);
const tag = isNum
? (await db`select id, tag, normalized from tags where id = ${+tagParam}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagParam}) or tag = ${tagParam}`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
@@ -135,7 +151,7 @@ export default router => {
join tags t on t.id = et.id
`;
return res.json({ success: true, tags }, 200);
return res.json({ success: true, tags, tag }, 200);
});
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
+7
View File
@@ -6,6 +6,7 @@ import cfg from "../../config.mjs";
import fs from "fs";
import path from "path";
import { logAnonActivity } from "../../anon_auth.mjs";
import { canAnonDo, isAnonSession } from "../../settings.mjs";
export default router => {
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
@@ -26,6 +27,9 @@ export default router => {
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
if (isAnonSession(req.session) && !canAnonDo('tag')) {
return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
}
const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
return res.json({
@@ -106,6 +110,9 @@ export default router => {
});
group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
}
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
const postid = +req.params.postid;
+16 -4
View File
@@ -3,7 +3,7 @@ import { spawn as _spawnRaw } from 'child_process';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import { getEnableItemSlugs, canAnonDo, isAnonSession } from '../../settings.mjs';
import { applyWordFilter } from '../../wordfilter.mjs';
import queue from '../../queue.mjs';
import path from "path";
@@ -232,9 +232,21 @@ const collectBody = (req) => {
export default router => {
router.group(/^\/api\/v2/, group => {
const uploadApiAuth = (req, res, next) => {
if (!req.session) {
return res.json({ success: false, msg: 'Unauthorized' }, 401);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('upload')) {
return res.json({ success: false, msg: 'Action requires a registered account or anonymous upload permission' }, 403);
}
return next();
}
return lib.registeredUser(req, res, next);
};
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.registeredUser, (req, res) => {
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, uploadApiAuth, (req, res) => {
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
const state = progressMap.get(jobId);
res.setHeader?.('Cache-Control', 'no-store');
@@ -307,7 +319,7 @@ export default router => {
return [...new Set(tags)];
};
group.get(/\/meta\/extract-url$/, lib.registeredUser, async (req, res) => {
group.get(/\/meta\/extract-url$/, uploadApiAuth, async (req, res) => {
const url = req.url.qs?.url;
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
@@ -358,7 +370,7 @@ export default router => {
}
});
group.post(/\/upload-url$/, lib.registeredUser, async (req, res) => {
group.post(/\/upload-url$/, uploadApiAuth, async (req, res) => {
try {
if (!cfg.websrv.web_url_upload) {
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
+14 -6
View File
@@ -31,10 +31,15 @@ export default (router, tpl) => {
}
}
if (!isBanned) {
return res.writeHead(302, {
"Location": "/"
}).end();
if (req.cookies && req.cookies.f0ck_banned) {
try {
const bData = JSON.parse(decodeURIComponent(req.cookies.f0ck_banned));
if (bData && (bData.reason || bData.banned)) {
isBanned = true;
reason = bData.reason || reason;
expires = bData.expires || expires;
}
} catch (e) {}
}
res.reply({
@@ -42,8 +47,11 @@ export default (router, tpl) => {
session: req.session,
reason: reason,
expires: expires ? new Date(expires).toLocaleString() : 'Permanent',
ban_video: cfg.websrv.ban_video,
hideNavbar: true
isBanned: isBanned,
clientIp: clientIp,
page_meta: {
title: isBanned ? 'Banned' : 'Ban Status'
}
}, req)
});
});
+15 -5
View File
@@ -8,7 +8,7 @@ import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { getEnableAnonymousAccess } from "../settings.mjs";
import { getEnableAnonymousAccess, canAnonDo, isAnonSession, isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
@@ -65,7 +65,7 @@ export default (router, tpl) => {
}
// Transform for frontend if needed, or send as is
const anonymize = !req.session && cfg.main.guest_anonymize;
const anonymize = isAnonymizeSession(req.session);
const outComments = anonymize
? comments.map(c => ({
...c,
@@ -135,8 +135,8 @@ export default (router, tpl) => {
// Browse User Comments
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
const user = decodeURIComponent(req.params.user);
@@ -413,6 +413,10 @@ export default (router, tpl) => {
}
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
if (isAnonSession(req.session) && !canAnonDo('comment')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Anonymous commenting is disabled" }) });
}
// Rate limit regular users (admins and mods are exempt)
if (!req.session.admin && !req.session.is_moderator) {
if (isCommentRateLimited(req.session.id)) {
@@ -1091,6 +1095,8 @@ export default (router, tpl) => {
? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
: db`AND COALESCE(i.visibility, 0) = 0`);
const { mimeSQL: activityMimeSQL } = f0cklib.resolveMimeSQL(req.url.qs?.mime || (req.cookies?.mime || null), req.session, 'i');
const comments = await db`
SELECT
c.*,
@@ -1117,6 +1123,7 @@ export default (router, tpl) => {
WHERE c.is_deleted = false
AND i.active = true
AND i.is_deleted = false
${activityMimeSQL}
${visibilityFilter}
AND ${db.unsafe(modequery)}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
@@ -1195,7 +1202,7 @@ export default (router, tpl) => {
}
}
const isAnonymized = !req.session && cfg.main.guest_anonymize;
const isAnonymized = isAnonymizeSession(req.session);
const processedComments = comments.map(c => {
let ratingLabel = '?';
let ratingClass = 'untagged';
@@ -1431,6 +1438,9 @@ export default (router, tpl) => {
// POST /api/polls/:pollId/vote — cast or change vote
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (isAnonSession(req.session) && !canAnonDo('poll_vote')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Anonymous poll voting is disabled' }) });
}
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
+36 -10
View File
@@ -4,6 +4,7 @@ import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { createI18n } from "../i18n.mjs";
import { render502 } from "../private_items.mjs";
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession } from "../settings.mjs";
const auth = async (req, res, next) => {
if (!req.session)
@@ -13,9 +14,9 @@ const auth = async (req, res, next) => {
export default (router, tpl) => {
router.get(/\/user\/(?<user>[^/]+)\/?$/, async (req, res) => {
// When guest anonymization is active, user profiles must not be accessible without a session
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
// When anonymization is active, user profiles must not be accessible without an authenticated regular session
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
const user = decodeURIComponent(req.params.user);
const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null);
@@ -228,17 +229,24 @@ export default (router, tpl) => {
return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
}
// When guest anonymization is active, user gallery pages must require authentication
if (cfg.main.guest_anonymize && !req.session && req.params.user && req.params.mode) {
return res.redirect('/login');
}
// Redirect anonymous users requesting /user/anonymous/favs to their personal shadow username favs
if (req.params.mode === 'favs' && req.params.user?.toLowerCase() === 'anonymous' && req.session?.is_anon && req.session?.login) {
res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` });
return res.end();
}
// When anonymization is active, user gallery pages must require authentication and hide other users
if (isAnonymizeSession(req.session) && req.params.user && req.params.mode) {
const targetUser = decodeURIComponent(req.params.user).toLowerCase();
const isSelf = req.session?.user && (
targetUser === req.session.user.toLowerCase() ||
(req.session.login && targetUser === req.session.login.toLowerCase())
);
if (!isSelf) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
}
// Auto-persist strict mode from URL to session if it's there
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
@@ -247,6 +255,11 @@ export default (router, tpl) => {
// Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX
const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag;
// Track tag browsing interest in user affinity profile
if (reqTag && req.session?.id && !reqTag.startsWith('title:')) {
f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag: reqTag, scoreDelta: 2.0 }).catch(() => {});
}
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
user: req.params.user,
tag: reqTag,
@@ -405,8 +418,8 @@ export default (router, tpl) => {
// Hall columns for display
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
@@ -632,6 +645,19 @@ export default (router, tpl) => {
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
const mode = modeMatch ? +modeMatch[1] : 0;
if (isAnonSession(req.session)) {
if (!canAnonDo('filter') || !canAnonMode(mode)) {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
code: 403,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'Mode not permitted for anonymous users' })
});
}
return res.redirect('/');
}
}
if (cfg.allowedModes[mode]) {
if (req.session) {
req.session.mode = mode;
+133 -2
View File
@@ -3,10 +3,65 @@ import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { setMotd } from "../motd.mjs";
import security from "../security.mjs";
export const clients = new Set();
const activeTabs = new Map(); // sessionId -> tabId
export function broadcastBan(data) {
if (!data) return;
const targetUserIds = new Set();
if (data.userId) targetUserIds.add(+data.userId);
if (Array.isArray(data.userIds)) data.userIds.forEach(id => targetUserIds.add(+id));
const targetFps = new Set();
if (data.fingerprint) targetFps.add(data.fingerprint);
if (Array.isArray(data.fingerprints)) data.fingerprints.forEach(fp => targetFps.add(fp));
const targetHws = new Set();
if (data.hwFingerprint) targetHws.add(data.hwFingerprint);
if (Array.isArray(data.hwFingerprints)) data.hwFingerprints.forEach(hw => targetHws.add(hw));
const targetIps = new Set();
if (data.ip) targetIps.add(data.ip);
if (Array.isArray(data.ips)) data.ips.forEach(ip => targetIps.add(ip));
const targetIpHashes = new Set();
if (data.ipHash) targetIpHashes.add(data.ipHash);
if (Array.isArray(data.ipHashes)) data.ipHashes.forEach(h => targetIpHashes.add(h));
for (const client of clients) {
let isMatch = false;
if (client.userId && targetUserIds.has(+client.userId)) {
isMatch = true;
} else if (client.fingerprint && targetFps.has(client.fingerprint)) {
isMatch = true;
} else if (client.hwFingerprint && targetHws.has(client.hwFingerprint)) {
isMatch = true;
} else if (client.ip && targetIps.has(client.ip)) {
isMatch = true;
} else if (client.ipHash && (targetIpHashes.has(client.ipHash) || targetIps.has(client.ipHash))) {
isMatch = true;
}
if (isMatch) {
console.log(`[SSE] Delivering instant ban to client (userId: ${client.userId}, ip: ${client.ip}, tab: ${client.tabId})`);
client.send({
type: 'banned',
data: {
reason: data.reason || 'Violation of community rules',
expires: data.expires ? (isNaN(new Date(data.expires).getTime()) ? data.expires : new Date(data.expires).toLocaleString()) : 'Permanent',
redirect: '/banned'
}
});
setTimeout(() => {
client.close();
}, 1000);
}
}
}
// Broadcast the deduplicated online-user list to all connected clients
function broadcastChatPresence() {
const seen = new Set();
@@ -83,6 +138,17 @@ db.listen('warnings', (payload) => {
}
}).catch(err => console.error('DB Listen Warning error:', err));
// Global listener for bans
db.listen('bans', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Received ban event via database notify:`, data);
broadcastBan(data);
} catch (e) {
console.error('[SSE] Ban broadcast error:', e);
}
}).catch(err => console.error('[SSE] DB Listen Ban error:', err));
// Global listener for profile updates (display name changes etc.)
db.listen('profile_update', (payload) => {
try {
@@ -603,8 +669,16 @@ export default (router, tpl) => {
res.writeHead(200, headers);
res.write(': ok\n\n'); // Warmup
const clientIp = security.getRealIP(req);
const clientIpHash = security.hashIP(clientIp);
const clientFp = req.session?.fingerprint || req.session?.anon_fingerprint || req.url.qs?.fp || null;
const clientHw = req.session?.hw_fingerprint || req.url.qs?.hw || null;
const clientUserId = (req.session && typeof req.session === 'object') ? req.session.id : null;
const client = {
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
userId: clientUserId,
fingerprint: clientFp,
hwFingerprint: clientHw,
username: req.session?.user || null,
display_name: req.session?.display_name || null,
avatar_file: req.session?.avatar_file || null,
@@ -617,7 +691,8 @@ export default (router, tpl) => {
do_not_disturb: req.session?.do_not_disturb === true,
sessionId,
tabId,
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
ip: clientIp,
ipHash: clientIpHash,
send: (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -632,6 +707,62 @@ export default (router, tpl) => {
}
};
// Check if connecting client is already banned
(async () => {
let isBanned = false;
let banReason = 'Violation of community rules';
let banExpires = null;
if (client.userId) {
const u = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${client.userId} LIMIT 1`;
if (u[0]?.banned) {
isBanned = true;
banReason = u[0].ban_reason || banReason;
banExpires = u[0].ban_expires;
}
}
if (!isBanned && client.ip) {
const ipBan = await security.isIpBanned(client.ip);
if (ipBan) {
isBanned = true;
banReason = ipBan.reason || banReason;
banExpires = ipBan.expires;
}
}
if (!isBanned && client.fingerprint) {
const fpBan = await security.isFingerprintBanned(client.fingerprint);
if (fpBan) {
isBanned = true;
banReason = fpBan.reason || banReason;
banExpires = fpBan.expires;
}
}
if (!isBanned && client.hwFingerprint) {
const hwBan = await security.isHardwareBanned(client.hwFingerprint);
if (hwBan) {
isBanned = true;
banReason = hwBan.reason || banReason;
banExpires = hwBan.expires;
}
}
if (isBanned) {
console.log(`[SSE] Connecting client is already banned, pushing instant redirect to /banned`);
client.send({
type: 'banned',
data: {
reason: banReason,
expires: banExpires ? (isNaN(new Date(banExpires).getTime()) ? banExpires : new Date(banExpires).toLocaleString()) : 'Permanent',
redirect: '/banned'
}
});
setTimeout(() => client.close(), 1000);
}
})().catch(err => console.error('[SSE] Initial ban check error:', err));
// Send any unacknowledged warnings on connection
if (!isGuest && req.session?.id) {
db`
+6 -2
View File
@@ -43,17 +43,21 @@ export default (router, tpl) => {
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr);
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const reqQueryMime = req.url?.searchParams?.get('mime') || req.url?.qs?.mime;
const effectiveMime = (reqQueryMime !== undefined && reqQueryMime !== null) ? reqQueryMime : (cookieMime || opts.mime || null);
const data = await f0cklib.getRandom({
user: opts.user,
tag: opts.tag,
hall: opts.hall,
mime: opts.mime || (req.cookies.mime || null),
mime: effectiveMime,
page: opts.page,
fav: opts.mode === 'favs',
mode: req.mode,
ratings: ratingsArr,
strict: opts.strict,
session: !!req.session
session: req.session
});
console.log("data", data);
+2 -1
View File
@@ -2,6 +2,7 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
// Serve the scroller page
@@ -377,7 +378,7 @@ export default (router, tpl) => {
const lastItem = items[items.length - 1];
const nextCursor = lastItem ? lastItem.id : null;
const isAnonymized = !req.session && cfg.main.guest_anonymize;
const isAnonymized = isAnonymizeSession(req.session);
const outItems = isAnonymized
? items.map(item => ({
...item,
+4
View File
@@ -1,6 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import search from "../routeinc/search.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
const _eps = 20;
@@ -14,6 +15,9 @@ export default (router, tpl) => {
let pagination, link;
if (tag.length > 0) {
if (req.session?.id && typeof tag === 'string' && !tag.startsWith('src:') && !tag.startsWith('title:')) {
f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag, scoreDelta: 2.0 }).catch(() => {});
}
if (tag.startsWith('src:')) {
total = (await db`
select count(*) as total
+15 -2
View File
@@ -1,10 +1,23 @@
import lib from "../lib.mjs";
import db from "../sql.mjs";
import cfg from "../config.mjs";
import { getMinTags } from "../settings.mjs";
import { getMinTags, canAnonDo, isAnonSession } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/upload$/, lib.userauth, async (req, res) => {
const uploadAuth = (req, res, next) => {
if (!req.session) {
return res.redirect('/login');
}
if (isAnonSession(req.session)) {
if (!canAnonDo('upload')) {
return res.redirect('/login');
}
return next();
}
return lib.userauth(req, res, next);
};
router.get(/^\/upload$/, uploadAuth, async (req, res) => {
let maxfilesize = cfg.main.maxfilesize;
if (req.session.admin || req.session.is_moderator) {
maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
+9 -8
View File
@@ -1,5 +1,6 @@
import db from "../sql.mjs";
import cfg from "../config.mjs";
import { isAnonymizeSession } from "../settings.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import fs from "fs/promises";
import path from "path";
@@ -43,8 +44,8 @@ export default (router, tpl) => {
// List halls for a user
router.get(/^\/user\/(?<owner>[^/]+)\/halls\/?$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
const ownerName = decodeURIComponent(req.params.owner);
@@ -83,8 +84,8 @@ export default (router, tpl) => {
// Item grid for a user hall
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)(?:\/p\/(?<page>\d+))?\/?$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
const ownerName = decodeURIComponent(req.params.owner);
@@ -132,8 +133,8 @@ export default (router, tpl) => {
// Single item within a user hall
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)\/(?<itemid>\d+)\/?$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
if (cfg.websrv.userhalls_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
const ownerName = decodeURIComponent(req.params.owner);
@@ -185,8 +186,8 @@ export default (router, tpl) => {
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
// When guest anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (cfg.main.guest_anonymize && !req.session) {
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
+6 -2
View File
@@ -1,6 +1,7 @@
import db from "../../inc/sql.mjs";
import lib from "../../inc/lib.mjs";
import cfg from "../../inc/config.mjs";
import { isAnonymizeSession } from "../settings.mjs";
import url from "url";
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
@@ -100,6 +101,9 @@ export default (router, tpl) => {
// API endpoint for lazy loading tags for a user
router.get(/^\/api\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
if (isAnonymizeSession(req.session)) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Access denied" }) });
}
const userParam = decodeURIComponent(req.params.user);
const u = await db`
@@ -158,8 +162,8 @@ export default (router, tpl) => {
// Main tags page
router.get(/^\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
if (cfg.main.guest_anonymize && !req.session) {
return res.redirect('/login');
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
const userParam = decodeURIComponent(req.params.user);
+22 -5
View File
@@ -317,8 +317,8 @@ export default new class {
}
// 3. Ban the hardware fingerprint
const associatedHws = new Set();
if (banHardware) {
const associatedHws = new Set();
if (targetHwFingerprint) associatedHws.add(targetHwFingerprint);
if (userId) {
@@ -348,9 +348,8 @@ export default new class {
}
// 4. Cascade to associated IPs
const associatedIps = new Set();
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);
@@ -380,10 +379,12 @@ export default new class {
for (const ip of associatedIps) {
if (!ip || ip === 'unknown') continue;
const ipHash = this.hashIP(ip);
const isAlreadyHash = /^[a-f0-9]{64}$/i.test(ip);
const ipVal = ip;
const ipHash = isAlreadyHash ? ip : this.hashIP(ip);
await db`
insert into banned_ips (ip, ip_hash, banned_by, reason, expires_at)
values (${ip}, ${ipHash}, ${bannedBy}, ${reason}, ${expires})
values (${ipVal}, ${ipHash}, ${bannedBy}, ${reason}, ${expires})
on conflict (ip) do update
set reason = excluded.reason,
expires_at = excluded.expires_at,
@@ -393,6 +394,22 @@ export default new class {
}
}
// 5. Broadcast ban immediately via Postgres notify
try {
const hwList = Array.from(associatedHws).slice(0, 50);
const ipList = Array.from(associatedIps).slice(0, 50);
await db.notify('bans', JSON.stringify({
userId,
fingerprint: targetFingerprint,
hwFingerprints: hwList,
ips: ipList,
reason: (reason || 'Banned anonymous identity').substring(0, 300),
expires
}));
} catch (e) {
console.error('[SECURITY] Error notifying bans channel:', e);
}
return { success: true, userId, fingerprint: targetFingerprint, hwFingerprint: targetHwFingerprint };
}
};
+94
View File
@@ -35,6 +35,100 @@ export const getEnableAnonymousAccess = () => {
return true;
};
export const DEFAULT_ANON_PERMISSIONS = Object.freeze({
upload: false,
comment: true,
comment_attachments: false,
comment_vote: true,
poll_vote: true,
tag: true,
tag_vote: true,
favorite: true,
rate_item: false,
filter: true,
exclude_tags: true,
anonymize_users: false,
allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'],
allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf']
});
export const getAnonPermissions = () => {
const fromConfig = cfg.anonymous_permissions || cfg.websrv?.anonymous_permissions || {};
return {
...DEFAULT_ANON_PERMISSIONS,
...fromConfig
};
};
export const getAnonAnonymize = () => {
const perms = getAnonPermissions();
if (typeof perms.anonymize_users === 'boolean') return perms.anonymize_users;
if (typeof perms.anon_anonymize === 'boolean') return perms.anon_anonymize;
if (cfg.main && typeof cfg.main.anon_anonymize === 'boolean') return cfg.main.anon_anonymize;
if (cfg.main && typeof cfg.main.anonymous_anonymize === 'boolean') return cfg.main.anonymous_anonymize;
if (typeof cfg.anon_anonymize === 'boolean') return cfg.anon_anonymize;
if (typeof cfg.anonymous_anonymize === 'boolean') return cfg.anonymous_anonymize;
return false;
};
export const isAnonymizeSession = (session) => {
if (!session || typeof session !== 'object' || !session.user) {
return !!(cfg.main?.guest_anonymize ?? cfg.guest_anonymize);
}
if (session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))) {
return getAnonAnonymize();
}
return false;
};
export const canAnonDo = (action) => {
if (!getEnableAnonymousAccess()) return false;
const perms = getAnonPermissions();
if (action === 'exclude_tags' || action === 'exclude_tag' || action === 'tag_exclude') {
if (perms.exclude_tags !== undefined) return !!perms.exclude_tags;
if (perms.exclude_tag !== undefined) return !!perms.exclude_tag;
if (perms.tag_exclude !== undefined) return !!perms.tag_exclude;
return perms.filter !== undefined ? !!perms.filter : true;
}
return perms[action] !== undefined ? !!perms[action] : !!DEFAULT_ANON_PERMISSIONS[action];
};
export const getAnonAllowedModes = () => {
const perms = getAnonPermissions();
return Array.isArray(perms.allowed_modes) ? perms.allowed_modes.map(m => String(m).toLowerCase()) : DEFAULT_ANON_PERMISSIONS.allowed_modes;
};
export const getAnonAllowedMimes = () => {
const perms = getAnonPermissions();
return Array.isArray(perms.allowed_mimes) ? perms.allowed_mimes.map(m => String(m).toLowerCase()) : DEFAULT_ANON_PERMISSIONS.allowed_mimes;
};
export const canAnonMode = (mode) => {
if (!canAnonDo('filter')) return false;
const allowed = getAnonAllowedModes();
const modeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
const name = typeof mode === 'number' ? modeNames[mode] : String(mode).toLowerCase();
return allowed.includes(name);
};
export const canAnonMime = (mime) => {
if (!canAnonDo('filter')) return false;
const allowed = getAnonAllowedMimes();
return allowed.includes(String(mime).toLowerCase());
};
export const isAnonSession = (session) => {
if (!session) return true;
if (session === true) return false;
if (typeof session !== 'object' || !session.user) return true;
return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_')));
};
export const checkAnonPermission = (session, action) => {
if (!isAnonSession(session)) return true;
return canAnonDo(action);
};
export const ensureAllItemsHaveSlugs = async () => {
try {
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
+13 -1
View File
@@ -20,7 +20,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, ensureAllItemsHaveSlugs } from "./inc/settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
@@ -1636,6 +1636,8 @@ process.on('uncaughtException', err => {
domain: cfg.main.url.domain,
hide_comments_from_public: cfg.main.hide_comments_from_public,
guest_anonymize: !!cfg.main.guest_anonymize,
anon_anonymize: false,
is_anonymized: false,
git_hash: typeof gitHash !== 'undefined' ? gitHash : 'unknown',
get motd() { return getMotd(); },
get manual_approval() { return getManualApproval(); },
@@ -1688,6 +1690,8 @@ process.on('uncaughtException', err => {
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
get enable_item_slugs() { return getEnableItemSlugs(); },
get enable_anonymous_access() { return getEnableAnonymousAccess(); },
get anon_permissions() { return getAnonPermissions(); },
get anon_permissions_json() { return JSON.stringify(getAnonPermissions()); },
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
nsfl_tag_id: cfg.nsfl_tag_id || 3,
@@ -1828,10 +1832,16 @@ process.on('uncaughtException', err => {
? data.user_alternative_steuerung
: (cfg.websrv.user_alternative_steuerung !== false));
const activeSession = activeReq?.session || data?.session || null;
const isAnonymized = isAnonymizeSession(activeSession);
const anonAnonymize = getAnonAnonymize();
data = Object.assign({}, globals, data || {}, {
t: perRequestT,
lang: perRequestLang,
recaptcha_enabled: perRequestRecaptcha,
is_anonymized: isAnonymized,
anon_anonymize: anonAnonymize,
user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
@@ -1843,6 +1853,8 @@ process.on('uncaughtException', err => {
? data.comment_display_mode
: (cfg.websrv.default_comment_display_mode || 0))
});
globals.is_anonymized = isAnonymized;
globals.anon_anonymize = anonAnonymize;
// Random brand image per-render
const brand = cfg.websrv.custom_brand_image;
+5 -3
View File
@@ -6,7 +6,7 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import https from "https";
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs, canAnonDo, isAnonSession } from "./inc/settings.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs";
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
@@ -121,8 +121,10 @@ export const handleUpload = async (req, res, self) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
return sendJson(res, { success: false, msg: 'Uploading requires a registered account' }, 403);
if (isAnonSession(req.session)) {
if (!canAnonDo('upload')) {
return sendJson(res, { success: false, msg: 'Uploading requires a registered account or anonymous upload permission' }, 403);
}
}
// CSRF validation — required for browser sessions, skipped for API key auth.