Files
f0ckm/src/inc/routeinc/f0cklib.mjs

1279 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import { updateHallsCache } from "../halls_cache.mjs";
import fs from "fs";
import url from "url";
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : 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);
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 < 5) 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 < 200000000) return { tier: 4, label: 'xDDDD' };
return { tier: 5, label: 'xDDDDD+' };
};
export default {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const tag = 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;
const actPage = +(page ?? 1);
// 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 eps = limit ?? cfg.websrv.eps;
const excludedTags = session && exclude ? (exclude || []) : [];
const newerThan = newer ? parseInt(newer) : null;
const minXd = (minXdScore && +minXdScore > 0) ? +minXdScore : 0;
// xD filter: use materialized items.xd_score column (kept live by trigger) for fast indexed lookup
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 tmp = { user, tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
const baseMode = lib.getMode(mode ?? 0);
const modequery = baseMode;
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 {
// Non-strict intersection Logic (AND for partials)
// For each term, ensure there is AT LEAST one matching tag assigned to the item
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 totalRows = await db`
select count(distinct items.id) as total
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
${tagFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
${!session && 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``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
`;
const total = Number(totalRows[0].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);
const rows = await db`
select
items.id,
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 on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
left join favorites on favorites.item_id = items.id
left join "user" fav_u on fav_u.id = favorites.user_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``})
left join tags badge_t on badge_t.id = ta.tag_id
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
where
${db.unsafe(modequery)}
and items.active = true
${tagFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
${!session && 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``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
group by items.id
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
offset ${newerThan ? 0 : offset}
limit ${eps}
`;
for (const row of rows) {
const meta = xdScoreMeta(row.xd_score);
row.xd_tier = meta.tier;
row.xd_label = meta.label;
}
// Dynamic thumb sizing: only on the unfiltered main feed.
// Profile pages, tag searches, halls, favorites, mime filters all use tier 1 (1×1).
const isMainFeed = cfg.websrv.enable_dynamic_thumbs
&& !rawUser && !rawTag && !rawHall && !rawUserHall && !rawMime && !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 });
// 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.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, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const tag = 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 itemid = rawItemid ? +rawItemid : null;
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, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
const effMode = Number(mode ?? 0);
const modequery = lib.getMode(effMode);
if (itemid === null) {
return {
success: false,
message: "404 - upload not found"
};
}
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}`;
}
}
}
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 = () => {
return db`
${db.unsafe(modequery)}
and items.active = true
${tagFilter}
${hallFilter}
${userHallFilter}
${fav ? db`and "user"."user" ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${!session && 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``}
`;
};
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
// 1. Fetch the main item
// We only apply the active check and global NSFW filter (for guests) here.
// We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible).
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.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
items.id = ${itemid} and
items.active = true
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
limit 1
`;
const actitem = items[0];
if (actitem && 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));
}
if (!actitem) {
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
if (!session && globalfilter) {
const unfilteredItem = await db`
select id from items where id = ${itemid} and active = true limit 1
`;
if (unfilteredItem[0]) {
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
id: itemid,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}_blur.webp`
}
};
}
}
return {
success: false,
message: "Sorry, this post is currently not visible."
};
}
// 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 = (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
const baseQuery = (whereClause, orderBy, limit = 1) => {
return 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
${fav
? 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
${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
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
${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);
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 user hall context
if (userHallObj && userHallOwner) {
const ownerName = userHallObj.owner_name || userHallOwner;
link.main = `/user/${encodeURIComponent(ownerName)}/hall/${encodeURIComponent(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
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}
`;
// Efficient coverart fallback
const coverartUrl = actitem.has_coverart
? `${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;
// 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)) 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) modeBlocked = true; // Untagged mode, item has tags
if (modeBlocked) {
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
id: itemid,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}${isNsfw ? '_blur' : ''}.webp`
}
};
}
}
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,
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_description: actitem.author_description,
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`,
coverart: coverartUrl,
dest: actitem.mime === 'video/youtube' ? actitem.dest : `${cfg.websrv.paths.images}/${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
},
title: `${actitem.id} - ${cfg.websrv.domain}`,
pagination: {
end: endItem[0]?.id || itemid,
start: startItem[0]?.id || itemid,
next: nextItem[0]?.id || null,
prev: prevItem[0]?.id || null,
page: 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, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const hall = rawHall || null;
const tag = 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 baseMode = lib.getMode(mode ?? 0);
const modequery = baseMode;
let item;
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
${mimeSQL}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : 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
${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 && 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, 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
${mimeSQL}
${!session && 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 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
${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)
const baseMode = lib.getMode(mode ?? 0);
const modequery = baseMode;
const tagId = (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
${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(modequery)}` : 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) => {
if (!itemId) 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,
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
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.item_id = ${itemId} 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
`;
// 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 = [];
}
}
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
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) => {
if (!userId || !itemId) return false;
const tStart = Date.now();
try {
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${itemId} AND is_subscribed = true`;
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${itemId} in ${Date.now() - tStart}ms`);
return sub.length > 0;
} catch (e) {
return false;
}
},
processMentions,
markNotificationsRead: async (userId, itemId) => {
if (!userId || !itemId) return;
try {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${itemId} 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``;
// Build mode condition using alias 'i' (getMode uses raw 'items' table name, incompatible with subquery alias)
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)`; // default: sfw
// Filter halls by their rating column to match the current mode
// 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
${modeFilter}
${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 };
}
},
computeXdScore,
xdScoreMeta
};