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
+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()
};