fdsafs
This commit is contained in:
+7
-2
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = ''`;
|
||||
|
||||
Reference in New Issue
Block a user