1210 lines
49 KiB
JavaScript
1210 lines
49 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; // 1pt per D: xD=1, xDD=2, xDDD=3, ...
|
|
}
|
|
}
|
|
return score;
|
|
};
|
|
|
|
const xdScoreMeta = (score) => {
|
|
if (score <= 0) return { tier: 0, label: '' };
|
|
if (score < 5) return { tier: 1, label: 'xD' };
|
|
if (score < 15) return { tier: 2, label: 'xDD' };
|
|
if (score < 30) return { tier: 3, label: 'xDDD' };
|
|
if (score < 60) 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
|
|
from items
|
|
left join "user" author_u on author_u.user = 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}
|
|
`;
|
|
|
|
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
|
|
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),
|
|
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
|
|
`;
|
|
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;
|
|
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
|
|
};
|