Files
f0ckm/src/inc/routeinc/f0cklib.mjs
T
2026-09-12 21:55:40 +02:00

2845 lines
112 KiB
JavaScript

import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs";
import fs from "fs";
import path from "path";
import url from "url";
const getGlobalfilter = () => {
if (!cfg.nsfp?.length) return null;
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(" or ") : null;
};
const computeBaseMode = (mode, ratings, session) => {
const effMode = Number(mode ?? 0);
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
// For guest sessions, sanitize ratingsArr to only allow permitted ratings
let safeRatingsArr = ratingsArr;
if (!session && ratingsArr) {
const allowedRatings = ['sfw'];
if (cfg.websrv.public_nsfw) allowedRatings.push('nsfw');
if (cfg.websrv.public_untagged) allowedRatings.push('untagged');
safeRatingsArr = ratingsArr.filter(r => allowedRatings.includes(r));
if (safeRatingsArr.length === 0) {
return "1 = 0";
}
}
let baseMode;
if (effMode === 2) {
if (safeRatingsArr && safeRatingsArr.includes('untagged') && safeRatingsArr.length > 1) {
baseMode = lib.getMultiRatingMode(safeRatingsArr);
} else {
baseMode = lib.getMode(2);
}
} else if (effMode === 3) {
baseMode = (safeRatingsArr && safeRatingsArr.length > 1) ? lib.getMultiRatingMode(safeRatingsArr) : lib.getMode(3);
} else {
const multiRatingSQL = safeRatingsArr ? lib.getMultiRatingMode(safeRatingsArr) : null;
baseMode = multiRatingSQL ?? lib.getMode(effMode);
}
if (!session) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if ((effMode === 0 || effMode === 3 || mode === undefined || mode === null) && (!safeRatingsArr || safeRatingsArr.length <= 1)) {
if (cfg.websrv.public_nsfw) {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
} else {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (effMode === 2) {
if (!cfg.websrv.public_untagged) {
baseMode = "1 = 0";
}
} else if (effMode === 1) {
if (!cfg.websrv.public_nsfw) {
baseMode = "1 = 0";
}
} else if (effMode === 4) {
baseMode = "1 = 0";
}
}
return baseMode;
};
const resolveNumericItemId = async (itemIdOrSlug) => {
if (!itemIdOrSlug) return null;
if (typeof itemIdOrSlug === 'number') return itemIdOrSlug;
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
try {
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
return rows[0]?.id || null;
} catch (e) {
return null;
}
};
// 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);
// ── 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
// between pages 1→192 with the same filters skips the COUNT entirely.
const COUNT_CACHE_TTL_MS = 90_000;
const countCache = new Map(); // key → { total, expiresAt }
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) {
return JSON.stringify([
modequery,
tag ?? '',
user ?? '',
hall ?? '',
mime ?? '',
fav ? 1 : 0,
session ? 1 : 0, // guests get globalfilter applied; members don't
excludedTags.slice().sort().join(','),
newerThan ?? '',
minXd,
userHallObj?.id ?? '',
tagger ?? ''
]);
}
function getCachedCount(key) {
const entry = countCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) { countCache.delete(key); return null; }
return entry.total;
}
function setCachedCount(key, total) {
countCache.set(key, { total, expiresAt: Date.now() + COUNT_CACHE_TTL_MS });
// Prevent unbounded growth — evict all expired entries when cache grows large
if (countCache.size > 500) {
const now = Date.now();
for (const [k, v] of countCache) { if (now > v.expiresAt) countCache.delete(k); }
}
}
// ────────────────────────────────────────────────────────────────────────────
const processMentions = async (comments) => {
if (!comments || comments.length === 0) return comments;
// 1. Collect all potential mentions
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
const allMentions = new Set();
comments.forEach(c => {
const matches = [...c.content.matchAll(mentionRegex)];
matches.forEach(m => allMentions.add(m[1].toLowerCase())); // normalize for lookup
});
if (allMentions.size === 0) return processEmbeds(comments);
// 2. Validate against DB
const validUsers = new Set();
try {
const users = await db`SELECT login FROM "user" WHERE login IN ${db([...allMentions])}`;
users.forEach(u => validUsers.add(u.login)); // login is lowercase
} catch (e) {
console.error('Error verifying mentions:', e);
return processEmbeds(comments); // Fail safe
}
// 3. Replace in content using original case from match but checking validity
const processed = comments.map(c => {
let newContent = c.content.replace(mentionRegex, (match, name) => {
if (validUsers.has(name.toLowerCase())) {
return `[@${name}](/user/${name})`;
}
return match;
});
return { ...c, content: newContent };
});
return processEmbeds(processed);
};
const processEmbeds = (comments) => {
if (!comments || comments.length === 0) return comments;
const siteUrl = cfg.main.url.full;
if (!siteUrl) return comments;
// Escape special characters in siteUrl for regex
const escapedSiteUrl = siteUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Regex to find site URLs pointing to images
// Supports .jpg, .jpeg, .png, .gif, .webp
// Case-insensitive
const imageRegex = new RegExp(`(${escapedSiteUrl}(?:\\/\\S+\\.(?:jpg|jpeg|png|gif|webp)))`, 'gi');
return comments.map(c => {
if (!c.content) return c;
let newContent = c.content.replace(imageRegex, (match, url) => {
return `![site image](${url})`;
});
return { ...c, content: newContent };
});
};
const computeXdScore = (comments) => {
if (!comments || comments.length === 0) return 0;
let score = 0;
const xdRegex = /x(D+)/gi;
for (const c of comments) {
if (!c.content || c.is_deleted) continue;
for (const m of c.content.matchAll(xdRegex)) {
score += m[1].length;
}
}
return score;
};
const xdScoreMeta = (score) => {
if (score < 1) return { tier: 0, label: '' };
if (score < 200) return { tier: 1, label: 'xD' };
if (score < 1000) return { tier: 2, label: 'xDD' };
if (score < 100000) return { tier: 3, label: 'xDDD' };
if (score < 20000000) return { tier: 4, label: 'xDDDD' };
return { tier: 5, label: 'xDDDDD+' };
};
async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}) {
if (!rawUser) return { isPrivate: false, isAllowed: true };
const decodedUser = decodeURI(rawUser);
const targetUserRows = await db`
select u.id, u."user", u.admin, uo.favorites_private
from "user" u
left join user_options uo on uo.user_id = u.id
where u."user" ilike ${decodedUser} or u.login ilike ${decodedUser}
limit 1
`;
if (!targetUserRows.length || !targetUserRows[0].favorites_private) {
return { isPrivate: false, isAllowed: true };
}
const targetUser = targetUserRows[0];
let reqUserId = user_id;
let reqIsAdmin = is_admin;
if (typeof session === 'object' && session !== null) {
if (reqUserId === undefined) reqUserId = session.id;
if (reqIsAdmin === undefined) reqIsAdmin = !!session.admin;
}
let isAllowed = false;
if (reqUserId) {
if (+reqUserId === +targetUser.id) {
isAllowed = true;
} else if (reqIsAdmin === true) {
isAllowed = true;
} else if (reqIsAdmin === undefined) {
const reqUserRows = await db`select admin from "user" where id = ${+reqUserId} limit 1`;
if (reqUserRows.length > 0 && reqUserRows[0].admin) {
isAllowed = true;
}
}
}
return { isPrivate: true, isAllowed };
}
const buildFeedFilters = async ({
rawUser,
rawTag,
rawHall,
rawMime,
mode,
ratings,
session,
strict,
exclude,
newer,
minXdScore,
rawUserHall,
rawUserHallOwner,
rawTagger
}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
const isTitleSearch = _decodedTag.startsWith('title:');
const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null;
const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null);
let hall = rawHall ?? null;
let hallObj = null;
if (hall) {
const hallData = await db`SELECT name, slug, description FROM halls WHERE slug = ${hall} LIMIT 1`;
if (hallData.length) {
hallObj = { name: hallData[0].name, slug: hallData[0].slug, description: hallData[0].description };
}
}
// User hall context
const userHallSlug = rawUserHall ?? null;
const userHallOwner = rawUserHallOwner ?? null;
let userHallObj = null;
if (userHallSlug && userHallOwner) {
const uhData = await db`
SELECT uh.id, uh.name, uh.slug, uh.description, uh.is_private, u."user" as owner_name
FROM user_halls uh
JOIN "user" u ON u.id = uh.user_id
WHERE u."user" ILIKE ${userHallOwner} AND uh.slug = ${userHallSlug}
LIMIT 1
`;
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 excludedTags = session && exclude ? (exclude || []) : [];
const newerThan = newer ? parseInt(newer) : null;
const minXd = (minXdScore && +minXdScore > 0) ? +minXdScore : 0;
const xdFilter = minXd > 0 ? db`and items.xd_score >= ${minXd}` : db``;
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0;
const tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
const modequery = computeBaseMode(mode, ratings, session);
let tagFilter = db``;
let titleFilter = db``;
if (isTitleSearch && titleQuery) {
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
} else if (tagger && tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
const conditions = terms.map(term => {
return db`and items.id in (
select ta.item_id from tags_assign ta
join tags t on t.id = ta.tag_id
join "user" u on u.id = ta.user_id
where t.normalized like '%' || slugify(${term}) || '%'
and u.user ilike ${tagger}
)`;
});
tagFilter = db`${conditions}`;
}
} else if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
if (isStrict) {
tagFilter = db`and items.id in (
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
having count(distinct t.normalized) = ${terms.length}
)`;
} else {
const conditions = terms.map(term => {
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
});
tagFilter = db`${conditions}`;
}
}
}
let hallFilter = db``;
if (hall) {
hallFilter = db`and items.id in (select item_id from halls_assign join halls on halls.id = halls_assign.hall_id where halls.slug = ${(hall && typeof hall === 'object') ? hall.slug : hall})`;
}
let userHallFilter = db``;
if (userHallObj) {
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
}
const isAdmin = !!session?.admin;
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
const visibilityFilter = isAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return {
user,
_decodedTag,
isTitleSearch,
titleQuery,
tag,
hall,
hallObj,
userHallSlug,
userHallOwner,
userHallObj,
mime,
mimeSQL,
excludedTags,
newerThan,
minXd,
xdFilter,
strictParams,
isStrict,
tagger,
modequery,
tagFilter,
titleFilter,
hallFilter,
userHallFilter,
visibilityFilter
};
};
const f0cklib = {
getItemPage: async ({
targetItemId,
targetItemPinned,
user: rawUser,
tag: rawTag,
hall: rawHall,
mime: rawMime,
mode,
ratings,
fav,
session,
limit,
strict,
newer,
exclude,
user_id,
is_admin,
userHall: rawUserHall,
userHallOwner: rawUserHallOwner,
minXdScore,
tagger: rawTagger
} = {}) => {
const numId = await resolveNumericItemId(targetItemId);
if (!numId) return 1;
let isPinned = targetItemPinned;
if (isPinned === undefined) {
const rows = await db`SELECT is_pinned FROM items WHERE id = ${numId} LIMIT 1`;
if (!rows.length) return 1;
isPinned = Boolean(rows[0].is_pinned);
} else {
isPinned = Boolean(isPinned);
}
const eps = limit ?? cfg.websrv.eps;
const filters = await buildFeedFilters({
rawUser,
rawTag,
rawHall,
rawMime,
mode,
ratings,
session,
strict,
exclude,
newer,
minXdScore,
rawUserHall,
rawUserHallOwner,
rawTagger
});
const {
user,
mimeSQL,
excludedTags,
newerThan,
xdFilter,
modequery,
tagFilter,
titleFilter,
hallFilter,
userHallFilter,
visibilityFilter
} = filters;
// Check if the target item actually matches this feed's criteria
const itemMatch = await db`
select 1 from items
${fav ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``}
where
items.id = ${numId}
and ${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
limit 1
`;
if (!itemMatch.length) {
return 1;
}
const countRows = await db`
select count(distinct items.id) as total_before
from items
${fav ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``}
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
and (
(items.is_pinned is true and ${isPinned} is false)
or (items.is_pinned = ${isPinned} and items.id > ${numId})
)
`;
const totalBefore = Number(countRows[0]?.total_before || 0);
return Math.floor(totalBefore / eps) + 1;
},
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger, ids, total: explicitTotal } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
const cleanIds = Array.isArray(ids)
? ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof ids === 'string' ? ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) : null);
if (cleanIds !== null && cleanIds.length === 0) {
return {
success: false,
message: "404 - no uploads found",
items: [],
total: 0
};
}
const idsFilter = (cleanIds && cleanIds.length > 0) ? db`and items.id = ANY(${cleanIds}::int[])` : db``;
const filters = await buildFeedFilters({
rawUser,
rawTag,
rawHall,
rawMime,
mode,
ratings,
session,
strict,
exclude,
newer,
minXdScore,
rawUserHall,
rawUserHallOwner,
rawTagger
});
const {
user,
_decodedTag,
isTitleSearch,
titleQuery,
tag,
hall,
hallObj,
userHallSlug,
userHallOwner,
userHallObj,
mime,
mimeSQL,
excludedTags,
newerThan,
minXd,
xdFilter,
strictParams,
isStrict,
tagger,
modequery,
tagFilter,
titleFilter,
hallFilter,
userHallFilter,
visibilityFilter
} = filters;
const actPage = +(page ?? 1);
const eps = limit ?? cfg.websrv.eps;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
let total = (explicitTotal !== undefined && explicitTotal !== null) ? Number(explicitTotal) : getCachedCount(cacheKey);
if (total === null) {
const totalRows = await db`
select count(distinct items.id) as total
from items
${fav && user ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``}
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
`;
total = Number(totalRows[0].total);
if (total > 0 && !cleanIds) setCachedCount(cacheKey, total);
}
if (!total || total === 0) {
return {
success: false,
message: "404 - no uploads found"
};
}
const pages = +Math.ceil(total / eps);
const act_page = Math.min(page || 1, pages);
const offset = Math.max(0, (act_page - 1) * eps);
// ── Deferred-join pagination ──────────────────────────────────────────────
// Step 1: Get only item IDs with all filters + OFFSET applied on the bare
// items table. No expensive JOINs here, so Postgres can use the
// (is_pinned DESC, id DESC) index efficiently even at page 192.
// The fav case still needs the favorites join in step 1 for the WHERE clause.
const pageIdRows = await db`
select items.id, items.is_pinned
from items
${fav && user ? db`
inner join favorites on favorites.item_id = items.id
inner join "user" fav_u on fav_u.id = favorites.user_id
` : db``}
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
${fav && user ? db`group by items.id, items.is_pinned` : db``}
order by ${
random ? db`random()` : (
(fav && !user && cleanIds && cleanIds.length > 0)
? db`array_position(${cleanIds}::int[], items.id)`
: db`items.is_pinned desc, items.id desc`
)
}
offset ${newerThan ? 0 : offset}
limit ${eps}
`;
if (pageIdRows.length === 0) {
// Off the end of the dataset (e.g. stale cached total sent user to a page that no longer exists)
return { success: false, message: "404 - no uploads found" };
}
const pageIds = pageIdRows.map(r => r.id);
// Preserve the page order returned by step 1 after the join scrambles it
const pageOrder = Object.fromEntries(pageIds.map((id, i) => [id, i]));
// Step 2: Enrich only those IDs — expensive JOINs on at most `eps` rows.
const rows = (await db`
select
items.id,
items.slug,
items.visibility,
items.mime,
items.dest,
items.username as username,
items.is_pinned,
items.is_oc,
items.xd_score,
${user_id ? db`max(coalesce(uvv.view_count, 0)) as my_views,` : db``}
${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`}
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag,
min(ta.tag_id) as tag_id,
max(uo.display_name) as display_name,
${cfg.websrv.enable_dynamic_thumbs ? db`
(
(SELECT count(*) FROM favorites WHERE item_id = items.id) +
(SELECT count(*) FROM comments WHERE item_id = items.id AND is_deleted = false)
) as contribution
` : db`0 as contribution`}
from items
left join "user" author_u on author_u."user" = items.username or author_u.login = items.username
left join user_options uo on uo.user_id = author_u.id
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2 ${cfg.enable_nsfl ? db`or ta.tag_id = ${cfg.nsfl_tag_id || 3}` : db``})
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
where items.id = any(${pageIds})
group by items.id
`).sort((a, b) => pageOrder[a.id] - pageOrder[b.id]);
// ─────────────────────────────────────────────────────────────────────────
for (const row of rows) {
const meta = xdScoreMeta(row.xd_score);
row.xd_tier = meta.tier;
row.xd_label = meta.label;
}
// Dynamic thumb sizing: applies to the main feed including mime/rating filters.
// Only per-user profiles, tag searches, halls, and favorites disable it.
const isMainFeed = cfg.websrv.enable_dynamic_thumbs
&& !rawUser && !rawTag && !rawHall && !rawUserHall && !fav;
if (isMainFeed) {
for (const row of rows) {
const c = Number(row.contribution) || 0;
row.thumb_size = c >= 5 ? 2 : 1;
}
} else {
for (const row of rows) {
row.thumb_size = 1;
}
}
const cheat = [];
// Increase range for better context
const range = 3;
for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++)
cheat.push(i);
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict, tagger });
// Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) {
link.main = `/tag/title:${encodeURIComponent(titleQuery)}/`;
link.mainDisplay = `/tag/title:${titleQuery}/`;
link.path = 'p/';
link.suffix = '';
}
// Override link for user hall context
if (userHallObj && userHallOwner) {
const ownerName = userHallObj.owner_name || userHallOwner;
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
link.mainDisplay = `/user/${ownerName}/hall/${userHallObj.slug}/`;
link.path = 'p/';
link.suffix = '';
}
return {
success: true,
items: rows,
pagination: {
start: 1,
end: pages,
current: act_page,
location: link.main + link.path,
suffix: link.suffix,
prev: (act_page > 1) ? act_page - 1 : null,
next: (act_page < pages) ? act_page + 1 : null,
page: act_page,
cheat: cheat
},
link,
tmp,
total,
view_mode: fav ? 'favs' : 'uploads'
};
},
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang, ids } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
const cleanIds = Array.isArray(ids)
? ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof ids === 'string' ? ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) : null);
const idsFilter = (cleanIds && cleanIds.length > 0) ? db`and items.id = ANY(${cleanIds}::int[])` : db``;
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
const isTitleSearch = _decodedTag.startsWith('title:');
const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null;
const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null);
let hall = rawHall ?? null;
if (hall) {
const hallData = await db`SELECT name, slug, description FROM halls WHERE slug = ${hall} LIMIT 1`;
if (hallData.length) {
hall = { name: hallData[0].name, slug: hallData[0].slug, description: hallData[0].description };
}
}
// User hall context
const userHallSlug = rawUserHall ?? null;
const userHallOwner = rawUserHallOwner ?? null;
let userHallObj = null;
if (userHallSlug && userHallOwner) {
const uhData = await db`
SELECT uh.id, uh.name, uh.slug, uh.description, uh.is_private, u."user" as owner_name
FROM user_halls uh
JOIN "user" u ON u.id = uh.user_id
WHERE u."user" ILIKE ${userHallOwner} AND uh.slug = ${userHallSlug}
LIMIT 1
`;
if (uhData.length) userHallObj = uhData[0];
}
const mime = (rawMime ?? "");
const rawIdOrSlug = rawItemid ?? null;
if (rawIdOrSlug === null || rawIdOrSlug === undefined || rawIdOrSlug === '') {
return {
success: false,
message: "404 - upload not found"
};
}
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 excludedTags = exclude || [];
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
const effMode = Number(mode ?? 0);
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const itemModeQuery = computeBaseMode(mode, ratings, session);
let tagFilter = db``;
let titleFilter = db``;
if (isTitleSearch && titleQuery) {
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
} else if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
if (isStrict) {
tagFilter = db`and items.id in (
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
having count(distinct t.normalized) = ${terms.length}
)`;
} else {
const conditions = terms.map(term => {
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
});
tagFilter = db`${conditions}`;
}
}
}
let hallFilter = db``;
if (hall) {
hallFilter = db`and items.id in (select item_id from halls_assign join halls on halls.id = halls_assign.hall_id where halls.slug = ${(hall && typeof hall === 'object') ? hall.slug : hall})`;
}
let userHallFilter = db``;
if (userHallObj) {
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
}
// Helper to construct shared filter conditions
const buildConditions = () => {
const isAdmin = !!session?.admin;
const visibilityFilter = isAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`);
return db`
${db.unsafe(itemModeQuery)}
and items.active = true
${visibilityFilter}
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
${tagFilter}
${titleFilter}
${hallFilter}
${userHallFilter}
${fav && user ? db`and "user"."user" ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${idsFilter}
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
`;
};
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
// 1. Fetch the main item
const items = await db`
select distinct on (items.id)
items.*,
items.username as username,
uo.username_color as author_color,
uo.display_name as author_display_name,
uo.avatar as author_avatar,
uo.avatar_file as author_avatar_file,
uo.banner_file as author_banner_file,
uo.banner_position as author_banner_position,
uo.banner_size as author_banner_size,
uo.description as author_description,
author_u.id as author_id,
items.is_pinned,
${user_id ? db`coalesce(uvv.view_count, 0) as my_views` : db`0 as my_views`}
from items
left join favorites on favorites.item_id = items.id
left join "user" fav_u on fav_u.id = favorites.user_id
left join "user" author_u on author_u."user" = items.username or author_u.login = items.username
left join "user_options" uo on uo.user_id = author_u.id
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
where
${itemLookup} and
items.active = true
limit 1
`;
const actitem = items[0];
if (!actitem) {
return {
success: false,
message: "404 - upload not found"
};
}
const itemid = actitem.id;
// Check visibility permissions:
const isOwnerOrAdmin = session && (
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
session.admin || session.is_moderator
);
// If item is Private (visibility === 2):
// Direct link only allowed for owner/admin
if (actitem.visibility === 2 && !isOwnerOrAdmin) {
return {
success: false,
is_private: true,
message: "403 - private upload"
};
}
// If item is Unavailable (visibility === 3):
// Only viewable by admins, not regular users or mods (renders normal post not found)
if (actitem.visibility === 3 && !session?.admin) {
return {
success: false,
message: "404 - upload not found"
};
}
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
// Block numeric enumeration unless viewer is owner/admin
if (isNumeric && (actitem.visibility === 1 || actitem.visibility === 2) && !isOwnerOrAdmin) {
return {
success: false,
message: "404 - upload not found"
};
}
if (user_id) {
db`
insert into user_video_views (user_id, video_id, view_count, last_viewed)
values (${user_id}, ${itemid}, 1, now())
on conflict (user_id, video_id) do update set
view_count = user_video_views.view_count + 1,
last_viewed = now()
`.catch(e => console.error('Failed to track view:', e));
}
// Guest rating restriction check for public items (unlisted items requested by direct link/slug bypass guest rating blocks)
if (!session && (actitem.visibility || 0) === 0) {
let blocked = false;
if (getGlobalfilter()) {
const filteredItem = await db`
select 1 from tags_assign where item_id = ${itemid} and (${db.unsafe(getGlobalfilter())}) limit 1
`;
if (filteredItem.length > 0) blocked = true;
}
if (!blocked && !cfg.websrv.public_untagged) {
const ratingTag = await db`
select 1 from tags_assign where item_id = ${itemid} and tag_id in (1, 2, ${nsflId}) limit 1
`;
if (ratingTag.length === 0) blocked = true;
}
if (blocked) {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
id: itemid,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}_blur.webp`,
og_url: hallSlug
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
: `https://${cfg.main.url.domain}/${itemid}`,
og_description: `Content not visible in current mode`
}
};
}
}
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
// Optimized: Use tags_assign driver for SFW (0) and NSFW (1) modes
// Determine the effective mode for optimization check (similar to Random)
const nsfl_id = cfg.nsfl_tag_id || 3;
const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall && (!cleanIds || cleanIds.length === 0);
const baseQuery = (whereClause, orderBy, limit = 1) => {
return db`
select items.id, items.slug
from items
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
${fav && user
? db`inner join favorites on favorites.item_id = items.id inner join "user" on "user".id = favorites.user_id`
: db`left join favorites on favorites.item_id = items.id left join "user" on "user".id = favorites.user_id`
}
where
${buildConditions()}
${whereClause}
group by items.id, items.slug
${orderBy}
limit ${limit}
`;
};
const optimizedBaseQuery = (whereClause, orderBy, limit = 1) => {
if (useTagsDriver) {
const modequery = lib.getMode(effMode);
const tagId = (effMode === 4 ? nsfl_id : (effMode === 1 ? 2 : 1));
const useTagIdOpt = !mimeParts.includes('audio');
const nsfpIds = cfg.nsfp || [];
const checkFilter = !session && nsfpIds.length > 0;
const query = db`
SELECT ta.item_id as id, items.slug
FROM tags_assign ta
INNER JOIN items ON items.id = ta.item_id
${checkFilter
? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = ta.item_id AND filter_ta.tag_id IN ${db(nsfpIds)}`
: db``
}
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
AND items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``}
${whereClause}
${orderBy}
LIMIT ${limit}
`;
return query;
}
return baseQuery(whereClause, orderBy, limit);
};
const runTimings = startTime;
const [nextItem, prevItem, startItem, endItem, cheatItems] = await Promise.all([
random ? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`) : optimizedBaseQuery(db`and items.id > ${itemid}`, db`order by items.id asc`),
random ? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`) : optimizedBaseQuery(db`and items.id < ${itemid}`, db`order by items.id desc`),
optimizedBaseQuery(db``, db`order by items.id asc`),
optimizedBaseQuery(db``, db`order by items.id desc`),
// Cheat items - try to get a few neighbors. Simplified: just get some newer ones
optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by abs(items.id - ${itemid}) asc`, 7)
]);
console.log(`[GETF0CK_OPT] Neighbor queries finished in ${Date.now() - runTimings}ms`);
// Cheat array should include current item and neighbors, sorted
const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b);
const tags = await lib.getTags(itemid, session);
const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`;
const userHallsForItem = user_id
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
: [];
const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: false });
// Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) {
link.main = `/tag/title:${encodeURIComponent(titleQuery)}/`;
link.mainDisplay = `/tag/title:${titleQuery}/`;
link.path = '';
link.suffix = '';
}
// Override link for user hall context
if (userHallObj && userHallOwner) {
const ownerName = userHallObj.owner_name || userHallOwner;
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(userHallObj.slug)}/`;
link.mainDisplay = `/user/${ownerName}/hall/${userHallObj.slug}/`;
link.path = '';
link.suffix = '';
}
const favorites = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
where "favorites".item_id = ${itemid}
`;
// Detect reposts: items uploaded with bypass_duplicate_check have checksum = `{hash}_bypass_{ts}`
// Find all items (including this one) that share the same base checksum.
let repostItems = [];
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
const baseChecksum = actitem.checksum.split('_bypass_')[0];
const repostRows = await db`
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
} else if (actitem.checksum) {
// Even without bypass, check if other bypass-entries exist with this same hash
const baseChecksum = actitem.checksum;
const repostRows = await db`
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND checksum LIKE ${baseChecksum + '_bypass_%'}
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
}
// Also find visually-similar items via phash, merging with checksum results
if (actitem.phash && actitem.phash !== 'ERROR' && actitem.phash !== 'MISSING') {
try {
const phashMatches = await queue.findallrepostphash(actitem.phash, itemid);
const existingIds = new Set(repostItems.map(r => r.id));
for (const pm of phashMatches) {
if (!existingIds.has(pm.id)) {
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
existingIds.add(pm.id);
}
}
repostItems.sort((a, b) => a.id - b.id);
} catch (e) {
console.error('[GETF0CK] phash repost lookup failed:', e.message);
}
}
// Efficient coverart fallback
let hasCoverart = actitem.has_coverart;
if (!hasCoverart && actitem.mime?.startsWith('audio/')) {
const caPath = path.join(cfg.paths.ca, `${actitem.id}.webp`);
try {
if (fs.existsSync(caPath)) {
hasCoverart = true;
db`UPDATE items SET has_coverart = TRUE WHERE id = ${actitem.id}`.catch(() => {});
}
} catch (_) {}
}
const coverartUrl = hasCoverart
? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`
: `/s/img/music.webp`;
const duration = Date.now() - startTime;
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Fetch complete in ${duration}ms`);
const isNsfl = cfg.enable_nsfl && tags.some(t => t.id == nsfl_id);
const isNsfw = tags.some(t => t.id == 2);
const isSfw = tags.some(t => t.id == 1);
const isTagged = tags.length > 0;
const isUntagged = !isSfw && !isNsfw && !isNsfl;
// Guest rating restriction check for public uploads (visibility === 0)
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
let guestBlocked = false;
if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
else if (isNsfl) guestBlocked = true;
else if (isUntagged && !cfg.websrv.public_untagged) guestBlocked = true;
if (guestBlocked) {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
id: itemid,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}${isNsfw ? '_blur' : ''}.webp`,
og_url: hallSlug
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
: `https://${cfg.main.url.domain}/${itemid}`,
og_description: `Content not visible in current mode`
}
};
}
}
// Mode-mismatch visibility check:
// Only enforce for members (session users) with an explicit mode preference.
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
const userMode = Number(mode ?? 0);
if (userMode !== 3) {
let modeBlocked = false;
if (userMode === 0 && (isNsfw || isNsfl || (!session && isUntagged && !cfg.websrv.public_untagged))) modeBlocked = true; // SFW mode, item is NSFW or NSFL
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
else if (userMode === 2 && (isTagged || (!session && !cfg.websrv.public_untagged))) modeBlocked = true; // Untagged mode, item has tags
if (modeBlocked && !isOwnerOrAdmin && actitem.visibility !== 1) {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
id: itemid,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}${isNsfw ? '_blur' : ''}.webp`,
og_url: hallSlug
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
: `https://${cfg.main.url.domain}/${itemid}`,
og_description: `Content not visible in current mode`
}
};
}
}
if (getEnableItemSlugs() && !actitem.slug) {
actitem.slug = lib.generateSlug(11);
db`UPDATE items SET slug = ${actitem.slug} WHERE id = ${actitem.id} AND (slug IS NULL OR slug = '')`.catch(e => console.error('[AUTO_SLUG] Failed DB update:', e.message));
}
const data = {
success: true,
user: {
name: actitem.username,
id: actitem.author_id,
color: actitem.author_color,
channel: actitem.usernetwork == "Telegram" && actitem.userchannel !== cfg.websrv.domain ? "anonymous" : actitem.userchannel,
network: actitem.usernetwork
},
item: {
id: actitem.id,
slug: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : null,
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
username: actitem.username,
author_id: actitem.author_id,
author_color: actitem.author_color,
author_display_name: actitem.author_display_name || null,
author_avatar: actitem.author_avatar,
author_avatar_file: actitem.author_avatar_file,
author_banner_file: actitem.author_banner_file,
author_banner_position: actitem.author_banner_position,
author_banner_size: actitem.author_banner_size,
author_description: actitem.author_description,
title: actitem.title || null,
src: {
long: actitem.src,
short: url.parse(actitem.src).hostname,
},
thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}.webp`,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}${(isNsfw || isNsfl) ? '_blur' : ''}.webp`,
// og_url: canonical URL for OG/bots — hall context preserved, plain /<id> as fallback
og_url: (() => {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
if (hallSlug) return `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${actitem.id}`;
return `https://${cfg.main.url.domain}/${actitem.id}`;
})(),
// og_description: include rating + uploader for bots (Matrix, Discord, etc.)
og_description: (() => {
const ratingLabel = isNsfl ? 'NSFL' : (isNsfw ? 'NSFW' : (isSfw ? 'SFW' : 'Untagged'));
const titlePart = actitem.title ? ` · "${actitem.title}"` : '';
return `${ratingLabel}${titlePart} · uploaded by ${actitem.username}`;
})(),
coverart: coverartUrl,
dest: (() => {
if (actitem.mime !== 'video/youtube') return `${cfg.websrv.paths.images}/${actitem.dest}`;
if (actitem.dest && actitem.dest.startsWith('yt:')) return actitem.dest;
// dest was corrupted by UUID backfill — recover from src
const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const m = actitem.src && actitem.src.match(ytSrcRegex);
return m ? `yt:${m[1]}` : actitem.dest;
})(),
mime: actitem.mime,
size: lib.formatSize(actitem.size),
checksum: actitem.checksum,
timestamp: {
timeago: lib.timeAgo(new Date(actitem.stamp * 1e3).toISOString(), lang),
timefull: new Date(actitem.stamp * 1e3).toISOString()
},
favorites: favorites,
tags: tags,
halls: itemHalls,
user_halls: userHallsForItem,
is_nsfw: isNsfw,
is_nsfl: isNsfl,
is_sfw: isSfw,
is_pinned: actitem.is_pinned || false,
is_comments_locked: actitem.is_comments_locked || false,
is_oc: actitem.is_oc || false,
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
reposts: repostItems,
show_repost_row: !!((session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (actitem.checksum?.includes('_bypass_') || (repostItems && repostItems.length > 0))),
width: actitem.width || null,
height: actitem.height || null,
original_filename: actitem.original_filename || null,
expires_at: actitem.expires_at || null,
expires_in: lib.expiresIn(actitem.expires_at)
},
title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
pagination: {
end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
cheat: cheat
},
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
link,
tmp
};
return data;
},
getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return null;
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const hall = rawHall || null;
// --- title: prefix — search items.title instead of the tags table ---
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
const isTitleSearch = _decodedTag.startsWith('title:');
const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null;
const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null);
const mime = (rawMime ?? "");
const userHallSlug = rawUserHall || null;
const userHallOwner = rawUserHallOwner || null;
// Resolve user hall to get its ID for filtering
let userHallId = null;
if (userHallSlug && userHallOwner) {
const uhRows = await db`
SELECT uh.id FROM user_halls uh
JOIN "user" u ON u.id = uh.user_id
WHERE u."user" ILIKE ${userHallOwner} AND uh.slug = ${userHallSlug}
LIMIT 1
`;
userHallId = uhRows[0]?.id || 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 excludedTags = session && exclude ? (exclude || []) : [];
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0;
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
const modequery = computeBaseMode(mode, ratings, session);
let item;
if (isTitleSearch && titleQuery) {
// Title search random: filter by items.title, no tag join needed
item = await db`
SELECT items.id
FROM items
WHERE
${db.unsafe(modequery)}
AND items.active = true
AND coalesce(items.visibility, 0) = 0
AND items.title ILIKE ${'%' + titleQuery + '%'}
AND items.title IS NOT NULL
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT 1
`;
} else if (fav && user) {
// Special case: random from user's favorites
item = await db`
select
items.id
from favorites
inner join items on favorites.item_id = items.id
inner join "user" on "user".id = favorites.user_id
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
where
${db.unsafe(modequery)}
and "user".user ilike ${user}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
group by items.id
order by random()
limit 1
`;
} else if (user || tag) {
// Normal random logic for filtered requests (user or tag specified)
let tagFilter = db``;
if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
if (isStrict) {
tagFilter = db`and items.id in (
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
having count(distinct t.normalized) = ${terms.length}
)`;
} else {
const conditions = terms.map(term => {
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
});
tagFilter = db`${conditions}`;
}
}
}
item = await db`
select
items.id
from items
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
where
${db.unsafe(modequery)}
and items.active = true
and coalesce(items.visibility, 0) = 0
${tagFilter}
${user ? db`and items.username ilike ${user}` : db``}
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
group by items.id, tags.tag
order by random()
limit 1
`;
} else if (hall) {
// Random within a site hall (no user or tag filter)
item = await db`
select
items.id
from items
join halls_assign ha on ha.item_id = items.id
join halls h on h.id = ha.hall_id
where
${db.unsafe(modequery)}
and h.slug = ${hall}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
order by random()
limit 1
`;
} else if (userHallId) {
// Random within a user hall
item = await db`
select items.id
from items
join user_halls_assign uha on uha.item_id = items.id
where
${db.unsafe(modequery)}
and uha.hall_id = ${userHallId}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
order by random()
limit 1
`;
} else {
// Uniform random logic for global requests (no user/tag/hall)
// When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation.
const globalModeQuery = modequery;
// tagId optimisation only applies for single native modes for logged-in users (not multi-rating or guest mode)
const tagId = session && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
: null;
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
const useTagIdOpt = tagId && !mimeParts.includes('audio');
const nsfpIds = cfg.nsfp || [];
const checkFilter = !session && nsfpIds.length > 0;
// Use a single uniform query with ORDER BY random()
// For 30k-100k items, this is performant enough and much more reliable than seeking.
item = await db`
SELECT items.id
FROM items
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
WHERE items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
${!useTagIdOpt ? db`AND ${db.unsafe(globalModeQuery)}` : db``}
ORDER BY random()
LIMIT 1
`;
}
if (item.length === 0) {
return {
success: false,
message: "no uploads found :("
};
}
const link = lib.genLink({ user, tag, hall, mime, type: fav ? 'favs' : 'uploads' });
return {
success: true,
link,
itemid: item[0].id
};
},
getComments: async (itemId, sort = 'new', process = true) => {
const numericId = await resolveNumericItemId(itemId);
if (!numericId) return [];
const tStart = Date.now();
try {
const comments = await db`
SELECT
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
COALESCE(c.is_pinned, false) as is_pinned,
c.video_time,
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat,
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count,
ai.fingerprint as anon_fingerprint
FROM comments c
JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id
LEFT JOIN anon_identities ai ON ai.user_id = u.id
WHERE c.item_id = ${numericId} AND c.is_deleted = false
ORDER BY COALESCE(c.is_pinned, false) DESC,
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
`;
for (const c of comments) {
if (c.anon_fingerprint) {
c.is_anon = true;
c.username = 'anonymous';
c.display_name = 'Anonymous';
c.anon_short_fingerprint = c.anon_fingerprint.slice(7, 15);
}
}
// Fetch comment file attachments
if (comments.length > 0) {
const commentIds = comments.map(c => c.id);
try {
const files = await db`
SELECT id, comment_id, dest, mime, size, original_filename
FROM comment_files
WHERE comment_id = ANY(${commentIds}::int[])
ORDER BY id ASC
`;
const filesMap = new Map();
for (const f of files) {
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
filesMap.get(f.comment_id).push(f);
}
for (const c of comments) {
c.files = filesMap.get(c.id) || [];
}
} catch (e) {
// Table might not exist yet, gracefully degrade
for (const c of comments) c.files = [];
}
// Fetch poll data for comments that have one
try {
const pollRows = await db`
SELECT
cp.id as poll_id,
cp.comment_id,
cp.question,
cp.expires_at,
COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg(
json_build_object(
'id', cpo.id,
'text', cpo.text,
'sort_order', cpo.sort_order,
'vote_count', COALESCE(vote_counts.cnt, 0)
) ORDER BY cpo.sort_order ASC, cpo.id ASC
) AS options,
COALESCE(SUM(vote_counts.cnt), 0)::int AS total_votes
FROM comment_polls cp
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
LEFT JOIN (
SELECT option_id, COUNT(*) AS cnt
FROM comment_poll_votes
GROUP BY option_id
) vote_counts ON vote_counts.option_id = cpo.id
WHERE cp.comment_id = ANY(${commentIds}::int[])
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
`;
// For non-anonymous polls, fetch voter names
const nonAnonIds = pollRows.filter(p => !p.is_anonymous).map(p => p.poll_id);
let votersByOption = new Map();
if (nonAnonIds.length > 0) {
const voterRows = await db`
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
FROM comment_poll_votes cpv
JOIN public."user" u ON u.id = cpv.user_id
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
WHERE cpv.poll_id = ANY(${nonAnonIds}::int[])
`;
for (const v of voterRows) {
if (!votersByOption.has(v.option_id)) votersByOption.set(v.option_id, []);
votersByOption.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
}
}
const pollMap = new Map();
for (const p of pollRows) {
const options = p.is_anonymous
? p.options
: p.options.map(o => ({ ...o, voters: votersByOption.get(o.id) || [] }));
pollMap.set(p.comment_id, {
id: p.poll_id,
question: p.question,
expires_at: p.expires_at,
is_anonymous: p.is_anonymous,
options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: null
});
}
for (const c of comments) {
c.poll = pollMap.get(c.id) || null;
}
} catch (e) {
console.error('[POLLS] getComments poll fetch error:', e.message, e.code);
for (const c of comments) c.poll = null;
}
}
console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`);
// Process mentions (now includes embeds)
return process ? await processMentions(comments) : comments;
} catch (e) {
console.error('[F0CKLIB] Error fetching comments:', e);
return [];
}
},
getComment: async (id, process = true) => {
if (!id) return null;
try {
const comment = await db`
SELECT
c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
COALESCE(c.is_pinned, false) as is_pinned,
c.video_time,
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat
FROM comments c
JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE c.id = ${id} AND c.is_deleted = false
LIMIT 1
`;
if (!comment.length) return null;
// Fetch comment file attachments
try {
const files = await db`
SELECT id, comment_id, dest, mime, size, original_filename
FROM comment_files
WHERE comment_id = ${id}
ORDER BY id ASC
`;
comment[0].files = files;
} catch (e) {
comment[0].files = [];
}
return process ? (await processMentions(comment))[0] : comment[0];
} catch (e) {
console.error('[F0CKLIB] Error fetching comment:', e);
return null;
}
},
getSubscriptionStatus: async (userId, itemId) => {
const numericId = await resolveNumericItemId(itemId);
if (!userId || !numericId) return false;
const tStart = Date.now();
try {
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${numericId} AND is_subscribed = true`;
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${numericId} in ${Date.now() - tStart}ms`);
return sub.length > 0;
} catch (e) {
return false;
}
},
processMentions,
markNotificationsRead: async (userId, itemId) => {
const numericId = await resolveNumericItemId(itemId);
if (!userId || !numericId) return;
try {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${numericId} AND is_read = false`;
} catch (e) {
console.error('[F0CKLIB] Error marking notifications as read:', e);
}
},
getHalls: async () => {
try {
return await db`
SELECT h.*,
COUNT(DISTINCT CASE WHEN i.active = true THEN ha.item_id END)::int AS item_count
FROM halls h
LEFT JOIN halls_assign ha ON ha.hall_id = h.id
LEFT JOIN items i ON i.id = ha.item_id
GROUP BY h.id
ORDER BY h.name ASC
`;
} catch (e) {
console.error('[F0CKLIB] Error fetching halls:', e);
return [];
}
},
getHallsOverview: async (mode = 0, excludedTags = []) => {
const userExcludeFilter = excludedTags.length > 0
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = i.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
: db``;
const modeNum = Number(mode) || 0;
// Filter halls by their rating column to match the current mode.
// The hall's own rating is the source of truth for mode gating — the old
// item-level modeFilter (tag_id check) caused NSFW halls to show 0 posts
// when items didn't carry the exact NSFW tag_id.
// mode 0=sfw -> rating='sfw', mode 1=nsfw -> rating='nsfw', mode 4=nsfl -> rating='nsfl'
// mode 3=all and mode 2=untagged show all halls
const hallRating = modeNum === 0 ? 'sfw' : modeNum === 1 ? 'nsfw' : modeNum === 4 ? 'nsfl' : null;
const ratingFilter = hallRating ? db`AND h.rating = ${hallRating}` : db``;
try {
return await db`
SELECT
h.id,
h.name,
h.slug,
h.description,
h.rating,
h.custom_image,
COALESCE(counts.total_items, 0) AS total_items,
counts.latest_item_id
FROM halls h
LEFT JOIN (
SELECT
ha.hall_id,
COUNT(DISTINCT ha.item_id) AS total_items,
MAX(i.id) AS latest_item_id
FROM halls_assign ha
JOIN items i ON i.id = ha.item_id
WHERE i.active = true
${userExcludeFilter}
GROUP BY ha.hall_id
) counts ON counts.hall_id = h.id
WHERE true ${ratingFilter}
ORDER BY h.name ASC
`;
} catch (e) {
console.error('[F0CKLIB] Error fetching halls overview:', e);
return [];
}
},
addItemToHall: async (itemId, hallInput, userId, description = null) => {
try {
// 1. Try to find by exact slug match (e.g. if selected from dropdown)
let hall = await db`SELECT id, slug FROM halls WHERE slug = ${hallInput} LIMIT 1`;
if (!hall.length) {
// 2. Not found by exact slug, so it's likely a new name or a manually typed existing name.
// Slugify the input to find a matching slug.
const generatedSlug = lib.slugify(hallInput);
hall = await db`SELECT id FROM halls WHERE slug = ${generatedSlug} LIMIT 1`;
if (!hall.length) {
// 3. Truly new hall - create it
// Use the original input (trimmed) for the display name, but use the generated slug.
const hallName = hallInput.trim();
if (!hallName || !generatedSlug) throw new Error("Invalid hall name");
await db`INSERT INTO halls (name, slug, description) VALUES (${hallName}, ${generatedSlug}, ${description})`;
hall = await db`SELECT id FROM halls WHERE slug = ${generatedSlug} LIMIT 1`;
// Update global cache (if there's a cached list used elsewhere)
try {
if (typeof updateHallsCache === 'function') await updateHallsCache();
} catch (ce) {}
} else if (description) {
// Existing hall found by slug but created/selected, update description if provided
await db`UPDATE halls SET description = ${description} WHERE id = ${hall[0].id}`;
}
} else if (description) {
// Found by exact slug, update description if provided
await db`UPDATE halls SET description = ${description} WHERE id = ${hall[0].id}`;
}
const insertResult = await db`
INSERT INTO halls_assign (hall_id, item_id, user_id)
VALUES (${hall[0].id}, ${+itemId}, ${userId})
ON CONFLICT (hall_id, item_id) DO NOTHING
`;
if (insertResult.count === 0) {
return { success: false, message: 'Item is already in this hall' };
}
return { success: true };
} catch (e) {
console.error('[F0CKLIB] Error adding item to hall:', e);
return { success: false, message: e.message };
}
},
updateHallMetadata: async (hallSlug, description) => {
try {
const result = await db`UPDATE halls SET description = ${description} WHERE slug = ${hallSlug}`;
if (result.count === 0) throw new Error('Hall not found');
// Update global cache
try {
if (typeof updateHallsCache === 'function') await updateHallsCache();
} catch (ce) {}
return { success: true };
} catch (e) {
console.error('[F0CKLIB] Error updating hall metadata:', e);
return { success: false, message: e.message };
}
},
removeItemFromHall: async (itemId, hallSlug) => {
try {
const hall = await db`SELECT id FROM halls WHERE slug = ${hallSlug} LIMIT 1`;
if (!hall.length) throw new Error('Hall not found');
await db`
DELETE FROM halls_assign
WHERE hall_id = ${hall[0].id} AND item_id = ${+itemId}
`;
return { success: true };
} catch (e) {
console.error('[F0CKLIB] Error removing item from hall:', e);
return { success: false, message: e.message };
}
},
// ── User Hall helpers ──────────────────────────────────────────────────────
getUserHalls: async (userId, mode = 0, excludedTags = [], viewerUserId = null) => {
const modeNum = Number(mode) || 0;
const modeFilter = modeNum === 1 ? db`AND i.id IN (SELECT item_id FROM tags_assign WHERE tag_id = 2)`
: modeNum === 2 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = i.id)`
: modeNum === 3 ? db``
: db`AND i.id IN (SELECT item_id FROM tags_assign WHERE tag_id = 1)`;
const userExcludeFilter = excludedTags.length > 0
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = i.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
: db``;
// Private halls: only visible to owner or admin
const privateFilter = (viewerUserId && viewerUserId === userId)
? db`` // owner sees all
: db`AND uh.is_private = false`;
try {
return await db`
SELECT
uh.id,
uh.name,
uh.slug,
uh.description,
uh.is_private,
uh.custom_image,
uh.created_at,
COALESCE(counts.total_items, 0) AS total_items,
counts.latest_item_id
FROM user_halls uh
LEFT JOIN (
SELECT
uha.hall_id,
COUNT(DISTINCT uha.item_id) AS total_items,
MAX(i.id) AS latest_item_id
FROM user_halls_assign uha
JOIN items i ON i.id = uha.item_id
WHERE i.active = true
${modeFilter}
${userExcludeFilter}
GROUP BY uha.hall_id
) counts ON counts.hall_id = uh.id
WHERE uh.user_id = ${userId}
${privateFilter}
ORDER BY uh.name ASC
`;
} catch (e) {
console.error('[F0CKLIB] Error fetching user halls:', e);
return [];
}
},
getUserHall: async (userId, slug) => {
try {
const rows = await db`
SELECT uh.*, u."user" as owner_name
FROM user_halls uh
JOIN "user" u ON u.id = uh.user_id
WHERE uh.user_id = ${userId} AND uh.slug = ${slug}
LIMIT 1
`;
return rows[0] || null;
} catch (e) {
console.error('[F0CKLIB] Error fetching user hall:', e);
return null;
}
},
getUserHallByOwnerName: async (ownerName, slug) => {
try {
const rows = await db`
SELECT uh.*, u."user" as owner_name
FROM user_halls uh
JOIN "user" u ON u.id = uh.user_id
WHERE u."user" ILIKE ${ownerName} AND uh.slug = ${slug}
LIMIT 1
`;
return rows[0] || null;
} catch (e) {
console.error('[F0CKLIB] Error fetching user hall by owner name:', e);
return null;
}
},
createUserHall: async (userId, name, slug, description = null) => {
try {
if (!name || !slug) throw new Error('Missing name or slug');
const exists = await db`SELECT id FROM user_halls WHERE user_id = ${userId} AND slug = ${slug} LIMIT 1`;
if (exists.length) return { success: false, message: 'A hall with this slug already exists' };
const result = await db`
INSERT INTO user_halls (user_id, name, slug, description)
VALUES (${userId}, ${name}, ${slug}, ${description || null})
RETURNING id, name, slug
`;
return { success: true, hall: result[0] };
} catch (e) {
console.error('[F0CKLIB] Error creating user hall:', e);
return { success: false, message: e.message };
}
},
updateUserHall: async (userId, slug, { name, newSlug, description, is_private }) => {
try {
const hall = await db`SELECT id FROM user_halls WHERE user_id = ${userId} AND slug = ${slug} LIMIT 1`;
if (!hall.length) return { success: false, message: 'Hall not found' };
const hallId = hall[0].id;
// Check slug conflict (if renaming)
if (newSlug && newSlug !== slug) {
const conflict = await db`SELECT id FROM user_halls WHERE user_id = ${userId} AND slug = ${newSlug} AND id != ${hallId} LIMIT 1`;
if (conflict.length) return { success: false, message: 'Slug already taken' };
}
await db`
UPDATE user_halls SET
name = COALESCE(${name ?? null}, name),
slug = COALESCE(${newSlug ?? null}, slug),
description = ${description !== undefined ? (description || null) : db`description`},
is_private = COALESCE(${is_private ?? null}, is_private)
WHERE id = ${hallId}
`;
return { success: true, newSlug: newSlug || slug };
} catch (e) {
console.error('[F0CKLIB] Error updating user hall:', e);
return { success: false, message: e.message };
}
},
deleteUserHall: async (userId, slug) => {
try {
const result = await db`DELETE FROM user_halls WHERE user_id = ${userId} AND slug = ${slug}`;
if (result.count === 0) return { success: false, message: 'Hall not found' };
return { success: true };
} catch (e) {
console.error('[F0CKLIB] Error deleting user hall:', e);
return { success: false, message: e.message };
}
},
addItemToUserHall: async (hallId, itemId, addedByUserId) => {
try {
// Verify item exists and is active
const item = await db`SELECT id FROM items WHERE id = ${+itemId} AND active = true LIMIT 1`;
if (!item.length) return { success: false, message: 'Item not found or not active' };
const result = await db`
INSERT INTO user_halls_assign (hall_id, item_id, user_id)
VALUES (${hallId}, ${+itemId}, ${addedByUserId})
ON CONFLICT (hall_id, item_id) DO NOTHING
`;
if (result.count === 0) return { success: false, message: 'Item is already in this hall' };
return { success: true };
} catch (e) {
console.error('[F0CKLIB] Error adding item to user hall:', e);
return { success: false, message: e.message };
}
},
removeItemFromUserHall: async (hallId, itemId) => {
try {
await db`DELETE FROM user_halls_assign WHERE hall_id = ${hallId} AND item_id = ${+itemId}`;
return { success: true };
} catch (e) {
console.error('[F0CKLIB] Error removing item from user hall:', e);
return { success: false, message: e.message };
}
},
getRandomRecommendations: async ({ limit = 20, mode, ratings, session, exclude, user_id, is_admin, mime, exclude_ids } = {}) => {
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const modequery = computeBaseMode(mode, ratingsArr, session);
const globalfilter = !session ? getGlobalfilter() : null;
const excludedTags = session && exclude ? (exclude || []) : [];
const maxLimit = Math.min(Math.max(1, Number(limit) || 20), 50);
const isOwnerOrAdmin = session && is_admin;
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && user_id
? 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 excludeItemIds = Array.isArray(exclude_ids)
? exclude_ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof exclude_ids === 'string'
? exclude_ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0)
: []);
const excludeIdsFilter = excludeItemIds.length > 0
? 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``;
let rows;
if (mimeParts.length > 0) {
rows = await db`
WITH rand_items AS (
SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
${mimeSQL}
${visibilityFilter}
${excludeIdsFilter}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${maxLimit}
)
SELECT
ri.*,
uo.display_name,
uo.username_color,
uo.avatar,
uo.avatar_file,
(SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = ri.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id,
ARRAY(
SELECT t.tag
FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ta.item_id = ri.id AND ta.tag_id NOT IN (1, 2, 3)
LIMIT 3
) as tags
FROM rand_items ri
LEFT JOIN "user" u ON LOWER(u."user") = LOWER(ri.username)
LEFT JOIN user_options uo ON uo.user_id = u.id
`;
} else {
// Balanced mix across all available media types: ensure audio/music gets guaranteed representation alongside images & videos
const audioLimit = Math.max(1, Math.min(3, Math.floor(maxLimit * 0.15)));
rows = await db`
WITH rand_audio AS (
SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
AND items.mime ILIKE 'audio/%'
${visibilityFilter}
${excludeIdsFilter}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${audioLimit}
),
rand_other AS (
SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
AND items.mime NOT ILIKE 'audio/%'
${visibilityFilter}
${excludeIdsFilter}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${maxLimit}
),
rand_items AS (
SELECT * FROM (
SELECT * FROM rand_audio
UNION ALL
SELECT * FROM rand_other
) sub
ORDER BY random()
LIMIT ${maxLimit}
)
SELECT
ri.*,
uo.display_name,
uo.username_color,
uo.avatar,
uo.avatar_file,
(SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = ri.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id,
ARRAY(
SELECT t.tag
FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ta.item_id = ri.id AND ta.tag_id NOT IN (1, 2, 3)
LIMIT 3
) as tags
FROM rand_items ri
LEFT JOIN "user" u ON LOWER(u."user") = LOWER(ri.username)
LEFT JOIN user_options uo ON uo.user_id = u.id
`;
}
return rows.map(r => {
const meta = xdScoreMeta(r.xd_score);
const tagId = r.rating_tag_id;
const ratingClass = tagId === 1 ? 'sfw' : (tagId === 2 ? 'nsfw' : (tagId === 3 ? 'nsfl' : 'untagged'));
return {
id: r.id,
title: r.title || null,
slug: r.slug || null,
mime: r.mime,
dest: r.dest,
username: r.username,
display_name: r.display_name || r.username,
username_color: r.username_color || null,
avatar: r.avatar || null,
avatar_file: r.avatar_file || null,
stamp: r.stamp,
tags: r.tags || [],
rating_tag_id: tagId,
rating_class: ratingClass,
xd_score: r.xd_score,
xd_tier: meta.tier,
xd_label: meta.label,
width: r.width,
height: r.height,
has_coverart: !!r.has_coverart,
score: 0,
rank_score: 0
};
});
},
getRandomVideos: (args) => f0cklib.getRandomRecommendations({ ...args, mime: 'video' }),
updateUserAffinity: async ({ user_id, item_id, scoreDelta = 1.0 }) => {
if (!user_id || !item_id || !scoreDelta) return;
try {
const itemInfo = await db`
SELECT i.username, COALESCE(array_agg(ta.tag_id) FILTER (WHERE ta.tag_id IS NOT NULL), '{}') as tag_ids
FROM items i
LEFT JOIN tags_assign ta ON ta.item_id = i.id
WHERE i.id = ${item_id}
GROUP BY i.id, i.username
LIMIT 1
`;
if (itemInfo.length === 0) return;
const { username: creator, tag_ids } = itemInfo[0];
// 1. Update tag affinities
if (tag_ids && tag_ids.length > 0) {
await db`
INSERT INTO user_tag_affinity (user_id, tag_id, score, interaction_count, last_interacted)
SELECT
${user_id},
unnest(${tag_ids}::int[]),
${scoreDelta},
1,
now()
ON CONFLICT (user_id, tag_id) DO UPDATE SET
score = GREATEST(-10.0, LEAST(1000.0, user_tag_affinity.score + EXCLUDED.score)),
interaction_count = user_tag_affinity.interaction_count + 1,
last_interacted = now()
`;
}
// 2. Update creator affinity
if (creator && creator.trim()) {
await db`
INSERT INTO user_creator_affinity (user_id, creator_username, score, interaction_count, last_interacted)
VALUES (${user_id}, ${creator.trim()}, ${scoreDelta}, 1, now())
ON CONFLICT (user_id, creator_username) DO UPDATE SET
score = GREATEST(-10.0, LEAST(1000.0, user_creator_affinity.score + EXCLUDED.score)),
interaction_count = user_creator_affinity.interaction_count + 1,
last_interacted = now()
`;
}
} catch (err) {
console.error("[AFFINITY] Failed to update user affinity:", err);
}
},
decayUserAffinities: async () => {
try {
await db`
UPDATE user_tag_affinity
SET score = score * 0.85
WHERE last_interacted < now() - interval '7 days' AND score > 0.1
`;
await db`
UPDATE user_creator_affinity
SET score = score * 0.85
WHERE last_interacted < now() - interval '7 days' AND score > 0.1
`;
await db`DELETE FROM user_tag_affinity WHERE score < 0.05 AND interaction_count < 2`;
await db`DELETE FROM user_creator_affinity WHERE score < 0.05 AND interaction_count < 2`;
} catch (err) {
console.error("[AFFINITY] Decay job error:", err);
}
},
getPersonalizedRecommendations: async ({
limit = 20,
mode,
ratings,
session,
exclude,
user_id,
is_admin,
mime,
exclude_ids,
session_tags = '',
session_creators = '',
continuation = false,
prefer_personalized = null
} = {}) => {
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const modequery = computeBaseMode(mode, ratingsArr, session);
const globalfilter = !session ? getGlobalfilter() : null;
const excludedTags = session && exclude ? (exclude || []) : [];
const maxLimit = Math.min(Math.max(1, Number(limit) || 20), 50);
const isOwnerOrAdmin = session && is_admin;
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && user_id
? 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 excludeItemIds = Array.isArray(exclude_ids)
? exclude_ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof exclude_ids === 'string'
? exclude_ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0)
: []);
// 1. Gather User & Session Profile
const tagMap = new Map();
const creatorSet = new Set();
if (user_id) {
try {
const dbTags = await db`
SELECT tag_id, score
FROM user_tag_affinity
WHERE user_id = ${user_id} AND score > 0
ORDER BY score DESC
LIMIT 30
`;
dbTags.forEach(r => tagMap.set(r.tag_id, Number(r.score)));
const dbCreators = await db`
SELECT creator_username
FROM user_creator_affinity
WHERE user_id = ${user_id} AND score > 0
ORDER BY score DESC
LIMIT 15
`;
dbCreators.forEach(r => { if (r.creator_username) creatorSet.add(r.creator_username); });
} catch (err) {
console.error("[RECS] Failed to load user affinity profile:", err);
}
}
// Process session tags (for guests or live in-session acceleration)
const stList = (typeof session_tags === 'string' ? session_tags.split(',') : (Array.isArray(session_tags) ? session_tags : []))
.map(s => s.trim().toLowerCase())
.filter(Boolean)
.slice(0, 20);
if (stList.length > 0) {
try {
const stRows = await db`SELECT id, tag FROM tags WHERE LOWER(tag) = ANY(${stList}::text[])`;
stRows.forEach(r => {
tagMap.set(r.id, (tagMap.get(r.id) || 0) + 8.0);
});
} catch (err) {
console.error("[RECS] Failed to resolve session tags:", err);
}
}
// Process session creators
const scList = (typeof session_creators === 'string' ? session_creators.split(',') : (Array.isArray(session_creators) ? session_creators : []))
.map(s => s.trim())
.filter(Boolean)
.slice(0, 15);
scList.forEach(c => creatorSet.add(c));
const targetTagEntries = Array.from(tagMap.entries()).filter(([tid, s]) => s > 0).slice(0, 35);
const targetCreators = Array.from(creatorSet);
// If cold start (no learned tags and no creators): fallback directly to pure random recommendations
if (targetTagEntries.length === 0 && targetCreators.length === 0) {
return f0cklib.getRandomRecommendations({
limit: maxLimit,
mode,
ratings,
session,
exclude,
user_id,
is_admin,
mime,
exclude_ids: excludeItemIds
});
}
// 2. Personalization vs Serendipity Split
// In the sidebar suggestions:
// - 1 of the first 3 recommendations is a personalized recommendation and the other 2 are not.
// - After that, recommendations appear sporadically (~20% rate, non-consecutive with 3-5 random items between them).
const isFirstBatch = !continuation && excludeItemIds.length === 0;
let personalizedTarget;
if (maxLimit === 1) {
if (prefer_personalized === true) {
personalizedTarget = 1;
} else if (prefer_personalized === false) {
personalizedTarget = 0;
} else {
personalizedTarget = Math.random() < 0.20 ? 1 : 0;
}
} else if (isFirstBatch) {
// 1 in top 3, plus sporadic recommendations in the remaining slots
const topRec = maxLimit >= 1 ? 1 : 0;
const remainingSlots = Math.max(0, maxLimit - 3);
const sporadicRecs = Math.round(remainingSlots * 0.20);
personalizedTarget = topRec + sporadicRecs;
} else {
// Continuation batch: sporadic recommendations throughout
personalizedTarget = Math.max(1, Math.round(maxLimit * 0.20));
}
const targetTagIds = targetTagEntries.map(([tid]) => tid);
const targetTagScores = targetTagEntries.map(([, score]) => score);
const excludeIdsFilter = excludeItemIds.length > 0
? 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``;
let personalizedItems = [];
if (personalizedTarget > 0 && targetTagIds.length > 0) {
try {
const poolLimit = Math.max(personalizedTarget * 3, 25);
const candidateRows = await db`
WITH user_tags AS (
SELECT unnest(${targetTagIds}::int[]) as tag_id, unnest(${targetTagScores}::real[]) as score
),
candidate_pool AS (
SELECT
items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart,
(
SUM(ut.score) * 1.5
+ CASE WHEN items.username = ANY(${targetCreators}::text[]) THEN 20.0 ELSE 0.0 END
+ (random() * 12.0)
) as rank_score
FROM items
JOIN tags_assign ta ON ta.item_id = items.id
JOIN user_tags ut ON ut.tag_id = ta.tag_id
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
${visibilityFilter}
${excludeIdsFilter}
${mimeSQL}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
GROUP BY items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
ORDER BY rank_score DESC
LIMIT ${poolLimit}
)
SELECT
cp.*,
uo.display_name,
uo.username_color,
uo.avatar,
uo.avatar_file,
(SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = cp.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id,
ARRAY(
SELECT t.tag
FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ta.item_id = cp.id AND ta.tag_id NOT IN (1, 2, 3)
LIMIT 3
) as tags
FROM (
SELECT * FROM candidate_pool ORDER BY random() LIMIT ${personalizedTarget}
) cp
LEFT JOIN "user" u ON LOWER(u."user") = LOWER(cp.username)
LEFT JOIN user_options uo ON uo.user_id = u.id
`;
personalizedItems = candidateRows.map(r => {
const meta = xdScoreMeta(r.xd_score);
const tagId = r.rating_tag_id;
const ratingClass = tagId === 1 ? 'sfw' : (tagId === 2 ? 'nsfw' : (tagId === 3 ? 'nsfl' : 'untagged'));
return {
id: r.id,
title: r.title || null,
slug: r.slug || null,
mime: r.mime,
dest: r.dest,
username: r.username,
display_name: r.display_name || r.username,
username_color: r.username_color || null,
avatar: r.avatar || null,
avatar_file: r.avatar_file || null,
stamp: r.stamp,
tags: r.tags || [],
rating_tag_id: tagId,
rating_class: ratingClass,
xd_score: r.xd_score,
xd_tier: meta.tier,
xd_label: meta.label,
width: r.width,
height: r.height,
has_coverart: !!r.has_coverart,
personalized: true,
score: r.rank_score != null ? Math.round(Number(r.rank_score) * 10) / 10 : 0,
rank_score: r.rank_score != null ? Math.round(Number(r.rank_score) * 10) / 10 : 0
};
});
} catch (err) {
console.error("[RECS] Error querying personalized candidate pool:", err);
}
}
// 3. Fetch Random Exploration Items
const neededRandom = maxLimit - personalizedItems.length;
let randomItems = [];
if (neededRandom > 0) {
const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)];
// Fetch extra buffer of random items to ensure ample spacing
randomItems = await f0cklib.getRandomRecommendations({
limit: Math.max(neededRandom + 3, maxLimit),
mode,
ratings,
session,
exclude,
user_id,
is_admin,
mime,
exclude_ids: allExclude
});
}
// 4. Combine & Interweave
if (maxLimit === 1) {
return personalizedItems.length > 0 ? personalizedItems.slice(0, 1) : randomItems.slice(0, 1);
}
if (personalizedItems.length === 0) {
return randomItems.slice(0, maxLimit);
}
if (randomItems.length === 0) {
return personalizedItems.slice(0, maxLimit);
}
const result = [];
if (isFirstBatch) {
// First 3 items: exactly 1 is a recommendation, and the other 2 are not
const topCount = Math.min(3, maxLimit);
const topSlots = new Array(topCount);
const recSlot = Math.floor(Math.random() * topCount);
topSlots[recSlot] = personalizedItems.shift();
for (let s = 0; s < topCount; s++) {
if (s !== recSlot) {
topSlots[s] = randomItems.length > 0 ? randomItems.shift() : personalizedItems.shift();
}
}
for (let s = 0; s < topCount; s++) {
if (topSlots[s]) result.push(topSlots[s]);
}
// Remaining slots: recommendations come sporadically (separated by 3 to 5 non-recommendations)
let gapSinceRec = topCount - 1 - recSlot;
let targetGap = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5 random items
while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) {
if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
targetGap = Math.floor(Math.random() * 3) + 3;
} else if (randomItems.length > 0) {
result.push(randomItems.shift());
gapSinceRec++;
} else if (personalizedItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
}
}
} else {
// Continuation batch: recommendations come sporadically throughout
let gapSinceRec = Math.floor(Math.random() * 2) + 1;
let targetGap = Math.floor(Math.random() * 3) + 3;
while (result.length < maxLimit && (personalizedItems.length > 0 || randomItems.length > 0)) {
if (personalizedItems.length > 0 && gapSinceRec >= targetGap && randomItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
targetGap = Math.floor(Math.random() * 3) + 3;
} else if (randomItems.length > 0) {
result.push(randomItems.shift());
gapSinceRec++;
} else if (personalizedItems.length > 0) {
result.push(personalizedItems.shift());
gapSinceRec = 0;
}
}
}
return result.slice(0, maxLimit);
},
getTagFeedItems: async ({
tag,
order = 'desc', // 'desc' = newest first (default), 'asc' = oldest first (chronological)
offset = 0,
limit = 20,
focus_id = null,
mode,
ratings,
session,
exclude,
user_id,
is_admin,
mime,
strict = false
} = {}) => {
if (!tag || !tag.trim()) return { items: [], total: 0 };
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const modequery = computeBaseMode(mode, ratingsArr, session);
const globalfilter = !session ? getGlobalfilter() : null;
const excludedTags = session && exclude ? (exclude || []) : [];
const maxLimit = Math.min(Math.max(1, Number(limit) || 20), 50);
const numOffset = Math.max(0, Number(offset) || 0);
const isOwnerOrAdmin = session && is_admin;
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && user_id
? 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 terms = tag.split(',').map(t => t.trim()).filter(Boolean);
const isStrict = !!strict || (tag && tag.includes(','));
let tagConditions;
if (isStrict) {
tagConditions = terms.length > 0 ? [db`and items.id in (
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
having count(distinct t.normalized) = ${terms.length}
)`] : [];
} else {
tagConditions = terms.map(term => {
return db`and items.id in (
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
where t.normalized like '%' || slugify(${term}) || '%'
)`;
});
}
const isDesc = String(order).toLowerCase() === 'desc';
const sortOrderItems = isDesc ? db`items.id desc` : db`items.id asc`;
const sortOrderMi = isDesc ? db`mi.id desc` : db`mi.id asc`;
let computedOffset = numOffset;
let computedLimit = maxLimit;
if (focus_id) {
const numFocusId = await resolveNumericItemId(focus_id);
if (numFocusId) {
try {
const rankRes = await db`
WITH ordered AS (
SELECT items.id, ROW_NUMBER() OVER (ORDER BY ${sortOrderItems}) - 1 as row_num
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
${visibilityFilter}
${mimeSQL}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
${tagConditions}
)
SELECT row_num FROM ordered WHERE id = ${numFocusId} LIMIT 1
`;
if (rankRes.length > 0) {
const rowNum = parseInt(rankRes[0].row_num, 10);
if (rowNum < 30) {
computedOffset = 0;
computedLimit = Math.min(50, Math.max(20, rowNum + 10));
} else {
computedOffset = Math.max(0, rowNum - 6);
computedLimit = 25;
}
}
} catch (err) {
console.error('[TAG-FEED] Error computing focus_id offset:', err);
}
}
}
const countRes = await db`
SELECT count(distinct items.id) as total
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
${visibilityFilter}
${mimeSQL}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
${tagConditions}
`;
const total = parseInt(countRes[0]?.total || 0, 10);
const rows = await db`
WITH matched_items AS (
SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
${visibilityFilter}
${mimeSQL}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
${tagConditions}
ORDER BY ${sortOrderItems}
OFFSET ${computedOffset}
LIMIT ${computedLimit}
)
SELECT
mi.*,
uo.display_name,
uo.username_color,
uo.avatar,
uo.avatar_file,
(SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = mi.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id,
ARRAY(
SELECT t.tag
FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ta.item_id = mi.id AND ta.tag_id NOT IN (1, 2, 3)
LIMIT 3
) as tags
FROM matched_items mi
LEFT JOIN "user" u ON LOWER(u."user") = LOWER(mi.username)
LEFT JOIN user_options uo ON uo.user_id = u.id
ORDER BY ${sortOrderMi}
`;
const items = rows.map(r => {
const meta = xdScoreMeta(r.xd_score);
const tagId = r.rating_tag_id;
const ratingClass = tagId === 1 ? 'sfw' : (tagId === 2 ? 'nsfw' : (tagId === 3 ? 'nsfl' : 'untagged'));
return {
id: r.id,
title: r.title || null,
slug: r.slug || null,
mime: r.mime,
dest: r.dest,
username: r.username,
display_name: r.display_name || r.username,
username_color: r.username_color || null,
avatar: r.avatar || null,
avatar_file: r.avatar_file || null,
stamp: r.stamp,
tags: r.tags || [],
rating_tag_id: tagId,
rating_class: ratingClass,
xd_score: r.xd_score,
xd_tier: meta.tier,
xd_label: meta.label,
width: r.width,
height: r.height,
has_coverart: !!r.has_coverart,
score: r.xd_score || 0,
rank_score: 0
};
});
return { items, total, offset: computedOffset, limit: computedLimit };
},
computeBaseMode,
getGlobalfilter,
processMentions,
processEmbeds,
computeXdScore,
xdScoreMeta,
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
clearCountCache: () => countCache.clear()
};
export default f0cklib;