1279 lines
51 KiB
JavaScript
1279 lines
51 KiB
JavaScript
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 ``;
|
||
});
|
||
|
||
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
|
||
};
|