This commit is contained in:
2026-09-18 00:32:53 +02:00
parent c8cfe07c70
commit 74f7884525
19 changed files with 1996 additions and 483 deletions
+17
View File
@@ -352,6 +352,23 @@ export default new class {
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
`;
// If album sub-item has no rating tag of its own, inherit the parent post's rating tag
const hasSubRating = tags.some(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t.id === 1 || t.id === 2);
if (!hasSubRating) {
const parentRatingTags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "tags_assign"
left join "tags" on "tags".id = "tags_assign".tag_id
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
where "tags_assign".item_id = ${+itemid}
and ("tags".id in (1, 2) or "tags".normalized in ('sfw', 'nsfw', 'nsfl'))
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc
limit 1
`;
if (parentRatingTags.length > 0) {
tags.unshift(parentRatingTags[0]);
}
}
} else {
tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
+4 -4
View File
@@ -182,8 +182,8 @@
"blur_nsfl_hint": "Blur NSFL-rated/shock thumbnails.",
"blur_sfw": "Blur SFW",
"blur_sfw_hint": "Blur SFW-rated thumbnails.",
"blur_untagged": "Blur Untagged",
"blur_untagged_hint": "Blur thumbnails with no tags or rating.",
"blur_untagged": "Blur Unrated",
"blur_untagged_hint": "Blur thumbnails with no rating.",
"blur_detail": "Click to reveal item on detail page",
"blur_detail_hint": "Require clicking to reveal blurred media on the post detail page.",
"render_emojis": "Render emojis in quote replies",
@@ -518,7 +518,7 @@
"tag_stats": "Stats",
"stat_total": "Total Items",
"stat_tagged": "Tagged",
"stat_untagged": "Untagged",
"stat_untagged": "Unrated",
"stat_sfw": "SFW content",
"stat_nsfw": "NSFW content",
"stat_nsfl": "NSFL content",
@@ -743,7 +743,7 @@
"reset_all": "Reset all",
"rating": "Rating",
"all": "All",
"untagged": "Untagged",
"untagged": "Unrated",
"media_type": "Media type",
"video": "Video",
"image": "Image",
+3 -1
View File
@@ -37,8 +37,10 @@ export async function initPrivateItems() {
if (r.id) _unavailableIds.set(Number(r.id), u);
}
}
if (!_initialized) {
console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) and ${_unavailableDests.size} unavailable item(s) into memory cache`);
}
_initialized = true;
console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) and ${_unavailableDests.size} unavailable item(s) into memory cache`);
} catch (err) {
console.error('[BOOT] Failed to load private/unavailable items into cache:', err.message);
}
+63 -38
View File
@@ -11,26 +11,26 @@ import url from "url";
const getGlobalfilter = (session) => {
if (!cfg.nsfp?.length) return null;
let filteredTags = [...cfg.nsfp];
// For anonymous users, respect their allowed_modes permissions
// allowed_modes controls access to NSFW/NSFL for anon/guests by stripping the RATING tags
// (tag_id 2 = NSFW, nsfl_tag_id = NSFL) from the nsfp filter.
// Any other nsfp tags (content-specific blocks) are never stripped — they always apply.
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (session && isAnonSession(session)) {
const allowedModes = getAnonAllowedModes();
if (allowedModes.includes('nsfw')) {
filteredTags = filteredTags.filter(id => id !== 2);
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (allowedModes.includes('nsfl')) {
filteredTags = filteredTags.filter(id => id !== nsflId);
}
} else if (!session) {
// Guest (not logged in): use public_nsfw setting
// Guest (not logged in): use public_nsfw / public_nsfl settings
if (cfg.websrv.public_nsfw) {
filteredTags = filteredTags.filter(id => id !== 2);
}
} else {
// Logged-in member: use public_nsfw setting
if (cfg.websrv.public_nsfw) {
filteredTags = filteredTags.filter(id => id !== 2);
}
// Logged-in member: rating tags are not filtered by nsfp
filteredTags = filteredTags.filter(id => id !== 2 && id !== nsflId);
}
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(" or ") : null;
};
@@ -363,7 +363,8 @@ const buildFeedFilters = async ({
rawUserHallOwner,
rawTagger
}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const user = rawUser ? decodeURI(rawUser) : null;
const userLike = user ? lib.escapeLike(user) : null;
// --- title: prefix — search items.title instead of the tags table ---
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
@@ -485,6 +486,7 @@ const buildFeedFilters = async ({
return {
user,
userLike,
_decodedTag,
isTitleSearch,
titleQuery,
@@ -568,6 +570,7 @@ const f0cklib = {
const {
user,
userLike,
mimeSQL,
excludedTags,
newerThan,
@@ -591,8 +594,8 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${fav ? db`and (fav_u.user ilike ${userLike} or fav_u.login ilike ${userLike})` : db``}
${!fav && user ? db`and items.username ilike ${userLike}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
@@ -617,8 +620,8 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${fav ? db`and (fav_u.user ilike ${userLike} or fav_u.login ilike ${userLike})` : db``}
${!fav && user ? db`and items.username ilike ${userLike}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
@@ -680,6 +683,7 @@ const f0cklib = {
const {
user,
userLike,
_decodedTag,
isTitleSearch,
titleQuery,
@@ -747,8 +751,8 @@ const f0cklib = {
${tagFilter}
${titleFilter}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${fav && user ? db`and (fav_u.user ilike ${userLike} or fav_u.login ilike ${userLike})` : db``}
${!fav && user ? db`and items.username ilike ${userLike}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
@@ -791,8 +795,8 @@ const f0cklib = {
${tagFilter}
${titleFilter}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${fav && user ? db`and (fav_u.user ilike ${userLike} or fav_u.login ilike ${userLike})` : db``}
${!fav && user ? db`and items.username ilike ${userLike}` : db``}
${mimeSQL}
${hallFilter}
${userHallFilter}
@@ -1089,7 +1093,8 @@ const f0cklib = {
: (typeof ids === 'string' ? ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) : null);
const idsFilter = (cleanIds && cleanIds.length > 0) ? db`and items.id = ANY(${cleanIds}::int[])` : db``;
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const user = rawUser ? decodeURI(rawUser) : null;
const userLike = user ? lib.escapeLike(user) : null;
// --- title: prefix — search items.title instead of the tags table ---
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
@@ -1222,8 +1227,8 @@ const f0cklib = {
${titleFilter}
${hallFilter}
${userHallFilter}
${fav && user ? db`and "user"."user" ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${fav && user ? db`and "user"."user" ilike ${userLike}` : db``}
${!fav && user ? db`and items.username ilike ${userLike}` : db``}
${idsFilter}
${mimeSQL}
${(!session || isAnonSession(session)) && getGlobalfilter(session) ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter(session))}))` : db``}
@@ -1579,17 +1584,22 @@ const f0cklib = {
}
}
const parentRatingTags = tags.filter(t => t.id == 1 || t.id == 2 || (cfg.enable_nsfl && (t.id == nsfl_id || t.normalized === 'nsfl')));
const isSubVisible = (subTags) => {
if (bypass_filter) return true;
const subIsNsfl = cfg.enable_nsfl && subTags.some(t => t.id == nsfl_id || t.normalized === 'nsfl');
const subIsNsfw = subTags.some(t => t.id == 2);
const subIsSfw = subTags.some(t => t.id == 1);
const hasSubRating = subTags.some(t => t.id == 1 || t.id == 2 || (cfg.enable_nsfl && (t.id == nsfl_id || t.normalized === 'nsfl')));
const effectiveSubTags = hasSubRating ? subTags : [...parentRatingTags, ...subTags];
const subIsNsfl = cfg.enable_nsfl && effectiveSubTags.some(t => t.id == nsfl_id || t.normalized === 'nsfl');
const subIsNsfw = effectiveSubTags.some(t => t.id == 2);
const subIsSfw = effectiveSubTags.some(t => t.id == 1);
const subIsUntagged = !subIsSfw && !subIsNsfw && !subIsNsfl;
// Excluded tags check
if (excludedTags && excludedTags.length > 0 && subTags.length > 0) {
if (subTags.some(t => excludedTags.includes(t.normalized) || excludedTags.includes(lib.slugify(t.tag)))) {
if (excludedTags && excludedTags.length > 0 && effectiveSubTags.length > 0) {
if (effectiveSubTags.some(t => excludedTags.includes(t.normalized) || excludedTags.includes(lib.slugify(t.tag)))) {
return false;
}
}
@@ -1664,10 +1674,16 @@ const f0cklib = {
album = await Promise.all(albumRows.map(async (r, idx) => {
const order = idx;
const subSlug = r.slug || r.id;
const isYouTube = r.mime === 'video/youtube';
const subBase = r.dest.replace(/\.[^.]+$/, '');
const isAudio = (r.mime || '').startsWith('audio/');
let subCover = null;
let subThumb = `${cfg.websrv.paths.thumbnails}/${subBase}.webp`;
// YouTube sub-items: thumbnail is stored by album_item row id (not by filename)
let subThumb = isYouTube
? `${cfg.websrv.paths.thumbnails}/${r.id}.webp`
: `${cfg.websrv.paths.thumbnails}/${subBase}.webp`;
// Also try YouTube CDN thumbnail as fallback
const ytVideoId = isYouTube ? r.dest.replace('yt:', '') : null;
if (isAudio) {
const caFile = path.join(cfg.paths.ca, `${subBase}.webp`);
@@ -1729,13 +1745,15 @@ const f0cklib = {
}
let subTags = tagsBySubId.get(r.id) || [];
const hasSubRating = subTags.some(t => t.id == 1 || t.id == 2 || (cfg.enable_nsfl && (t.id == nsfl_id || t.normalized === 'nsfl')));
const combinedSubTags = hasSubRating ? subTags : [...parentRatingTags, ...subTags];
return {
id: r.id,
slug: r.slug,
subf0ck_id: subSlug,
dest: `${cfg.websrv.paths.images}/${r.dest}`,
src: `${cfg.websrv.paths.images}/${r.dest}`,
dest: isYouTube ? r.dest : `${cfg.websrv.paths.images}/${r.dest}`,
src: isYouTube ? `https://www.youtube.com/embed/${ytVideoId}?enablejsapi=1` : `${cfg.websrv.paths.images}/${r.dest}`,
filename: r.dest,
mime: r.mime,
size: lib.formatSize(subSizeNum),
@@ -1746,12 +1764,16 @@ const f0cklib = {
display_index: order + 1,
is_first: order === 0,
is_video: (r.mime || '').startsWith('video/'),
is_youtube: isYouTube,
is_audio: isAudio,
is_image: (r.mime || '').startsWith('image/'),
has_coverart: isAudio ? !!subCover : false,
coverart: isAudio ? subCover : null,
thumb: subThumb,
tags: subTags
// Use YouTube CDN thumb as fallback for strip thumbnails
thumb: isYouTube
? `https://img.youtube.com/vi/${ytVideoId}/mqdefault.jpg`
: subThumb,
tags: combinedSubTags
};
}));
@@ -1797,11 +1819,12 @@ const f0cklib = {
actitem.coverart = targetSub.coverart;
}
if (targetSub.tags && targetSub.tags.length > 0) {
effectiveItemTags = targetSub.tags;
const hasSubRating = targetSub.tags.some(t => t.id == 1 || t.id == 2 || (cfg.enable_nsfl && (t.id == nsfl_id || t.normalized === 'nsfl')));
effectiveItemTags = hasSubRating ? targetSub.tags : [...parentRatingTags, ...targetSub.tags];
} else if (reqIdx === 0) {
effectiveItemTags = tags;
} else {
effectiveItemTags = [];
effectiveItemTags = [...parentRatingTags];
}
}
}
@@ -1944,7 +1967,7 @@ const f0cklib = {
})(),
// og_description: include rating + uploader for bots (Matrix, Discord, etc.)
og_description: (() => {
const ratingLabel = isNsfl ? 'NSFL' : (isNsfw ? 'NSFW' : (isSfw ? 'SFW' : 'Untagged'));
const ratingLabel = isNsfl ? 'NSFL' : (isNsfw ? 'NSFW' : (isSfw ? 'SFW' : 'Unrated'));
const titlePart = actitem.title ? ` · "${actitem.title}"` : '';
return `${ratingLabel}${titlePart} · uploaded by ${actitem.username}`;
})(),
@@ -2012,7 +2035,8 @@ const f0cklib = {
return null;
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const user = rawUser ? decodeURI(rawUser) : null;
const userLike = user ? lib.escapeLike(user) : null;
const hall = rawHall || null;
// --- title: prefix — search items.title instead of the tags table ---
@@ -2079,7 +2103,7 @@ const f0cklib = {
left join tags on tags.id = tags_assign.tag_id
where
${db.unsafe(modequery)}
and "user".user ilike ${user}
and "user".user ilike ${userLike}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
@@ -2121,7 +2145,7 @@ const f0cklib = {
and items.active = true
and coalesce(items.visibility, 0) = 0
${tagFilter}
${user ? db`and items.username ilike ${user}` : db``}
${user ? db`and items.username ilike ${userLike}` : 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 || isAnonSession(session)) && getGlobalfilter(session) ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter(session))}))` : db``}
@@ -2223,7 +2247,8 @@ const f0cklib = {
return { items: [], total: 0 };
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const user = rawUser ? decodeURI(rawUser) : null;
const userLike = user ? lib.escapeLike(user) : null;
const hall = rawHall || null;
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
@@ -2277,7 +2302,7 @@ const f0cklib = {
INNER JOIN items ON favorites.item_id = items.id
INNER JOIN "user" ON "user".id = favorites.user_id
WHERE ${db.unsafe(modequery)}
AND "user".user ILIKE ${user}
AND "user".user ILIKE ${userLike}
AND items.active = true AND coalesce(items.visibility, 0) = 0
${mimeSQL} ${globalFilter}
ORDER BY items.id
@@ -2310,7 +2335,7 @@ const f0cklib = {
WHERE ${db.unsafe(modequery)}
AND items.active = true AND coalesce(items.visibility, 0) = 0
${tagFilter}
${user ? db`and items.username ilike ${user}` : db``}
${user ? db`and items.username ilike ${userLike}` : 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} ${globalFilter} ${excludeFilter}
ORDER BY items.id
+25 -4
View File
@@ -101,7 +101,7 @@ export default (router, tpl) => {
if (!data.success) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 4: 'NSFL' };
const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null;
const errorHtml = tpl.render('error-partial', {
message: tErr('error.post_not_visible'),
@@ -220,6 +220,27 @@ export default (router, tpl) => {
// Detailed timing breakdown
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
let itemPage = null;
if (query.onara === '1' || req.cookies?.onara === '1' || req.cookies?.f0ck_onara === '1' || cfg.onara) {
try {
itemPage = await f0cklib.getItemPage({
targetItemId: data.item?.id || itemid,
targetItemPinned: data.item?.is_pinned,
user, tag, hall, userHall, userHallOwner, mime,
fav: isFav,
mode: reqMode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
is_admin: req.session?.admin,
minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0),
tagger: query.tagger || null
});
} catch (_) {}
}
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -228,7 +249,7 @@ export default (router, tpl) => {
title: data.title,
id: itemid,
slug: data.item?.slug || null,
page: null,
page: itemPage,
is_random: true
})
});
@@ -297,7 +318,7 @@ export default (router, tpl) => {
if (!data.success) {
const reqMode = (query.mode !== undefined ? +query.mode : req.mode) ?? 0;
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 4: 'NSFL' };
const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null;
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const errorHtml = tpl.render('error-partial', {
@@ -443,7 +464,7 @@ export default (router, tpl) => {
- Render: ${tAjaxRender - tAjaxAux}ms`);
let itemPage = null;
if (query.onara === '1' || req.cookies?.onara === '1' || cfg.onara || query.get_page === '1') {
if (query.onara === '1' || req.cookies?.onara === '1' || req.cookies?.f0ck_onara === '1' || cfg.onara || query.get_page === '1') {
try {
itemPage = await f0cklib.getItemPage({
targetItemId: data.item?.id || itemid,
+118
View File
@@ -878,6 +878,9 @@ export default router => {
});
console.log(`[RANDOM-POOL] ${pool.total} items in ${Date.now() - tStart}ms (mode=${mode} tag=${tag} hall=${hall} user=${user})`);
// Include nsfp_version so clients can detect when the nsfp list changes and invalidate their pool
pool.nsfp_version = (cfg.nsfp || []).slice().sort((a, b) => a - b).join(',');
return res.json(pool);
});
@@ -1037,6 +1040,102 @@ export default router => {
}
});
group.get(/\/favs-feed$/, async (req, res) => {
try {
const user = req.url.qs?.user ? String(req.url.qs.user).trim() : null;
if (!user) {
return res.json({ success: false, error: 'user is required', items: [], total: 0 }, 400);
}
const limit = Math.min(+(req.url.qs?.limit || 20), 50);
const offset = Math.max(+(req.url.qs?.offset || 0), 0);
const order = req.url.qs?.order === 'asc' ? 'asc' : 'desc';
const focusId = req.url.qs?.focus_id ? String(req.url.qs.focus_id).trim() : null;
let mode = req.mode ?? 0;
if (req.url.qs?.mode && ['0','1','2','3'].includes(req.url.qs.mode)) {
mode = parseInt(req.url.qs.mode);
}
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const result = await f0cklib.getf0cks({
user,
fav: true,
mode,
ratings: ratingsArr,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin,
limit,
page: Math.floor(offset / limit) + 1,
explicitTotal: null,
lang: req.lang
});
if (!result || !result.success) {
return res.json({ success: false, error: 'not found', items: [], total: 0 });
}
// Handle focus_id: if the focused item is on a different page, re-fetch centered on it
let items = result.items || [];
const total = result.total || 0;
let effectiveOffset = offset;
// If a focus_id is provided and not in current slice, try to find it
if (focusId && focusId !== 'favs' && items.length > 0 && !items.find(it => String(it.id) === focusId || it.slug === focusId)) {
const focusResult = await f0cklib.getItemPage({
targetItemId: focusId,
user,
fav: true,
mode,
ratings: ratingsArr,
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session?.excluded_tags || [],
limit
});
if (focusResult && focusResult > 0) {
const focusPage = focusResult;
effectiveOffset = (focusPage - 1) * limit;
const focusResult2 = await f0cklib.getf0cks({
user, fav: true, mode, ratings: ratingsArr,
session: req.session, exclude: req.session?.excluded_tags || [],
user_id: req.session?.id, is_admin: req.session?.admin,
limit, page: focusPage, explicitTotal: total, lang: req.lang
});
if (focusResult2?.success) {
items = focusResult2.items || [];
}
}
}
const isAnonUser = isAnonymizeSession(req.session);
const outItems = isAnonUser
? items.map(it => ({ ...it, username: 'anonymous', display_name: 'anonymous', username_color: null }))
: items;
// Sort by order preference (items from getf0cks are newest-first by default)
if (order === 'asc') outItems.reverse();
return res.json({
success: true,
user,
order,
total,
offset: effectiveOffset,
limit,
items: outItems,
hasMore: (effectiveOffset + outItems.length) < total
});
} catch (err) {
console.error('[FAVS-FEED] Error:', err);
return res.json({ success: false, error: 'Failed to load favs feed', items: [], total: 0 }, 500);
}
});
group.get(/\/tag-feed$/, async (req, res) => {
try {
const tag = req.url.qs?.tag ? String(req.url.qs.tag).trim() : null;
@@ -2134,6 +2233,25 @@ export default router => {
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
`;
}
// Propagate rating to all album sub-items if this is an album
const albumCheck = await sql`SELECT id FROM items WHERE id = ${itemid} AND is_album = true LIMIT 1`;
if (albumCheck.length > 0) {
await sql`
DELETE FROM album_items_tags_assign
WHERE album_item_id IN (SELECT id FROM album_items WHERE item_id = ${itemid})
AND tag_id IN (1, 2, ${nsfl_id})
`;
if (newRatingId > 0) {
await sql`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
SELECT id, ${newRatingId}, ${req.session.id}
FROM album_items
WHERE item_id = ${itemid}
ON CONFLICT DO NOTHING
`.catch(() => {});
}
}
}
// Ensure blurred thumbnail exists
+21
View File
@@ -351,6 +351,27 @@ export default router => {
VALUES (${+postid}, ${+tagid}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
// Ensure this album item also inherits parent's rating tag if it doesn't have one
const subRating = await db`
SELECT tag_id FROM album_items_tags_assign
WHERE album_item_id = ${+albumItemId} AND tag_id IN (1, 2, ${nsflId})
LIMIT 1
`;
if (subRating.length === 0) {
const parentRating = await db`
SELECT tag_id FROM tags_assign
WHERE item_id = ${+postid} AND tag_id IN (1, 2, ${nsflId})
LIMIT 1
`;
if (parentRating.length > 0) {
await db`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${+albumItemId}, ${parentRating[0].tag_id}, ${+req.session.id})
ON CONFLICT DO NOTHING
`.catch(() => {});
}
}
} else {
await db`
insert into "tags_assign" ${db({
+7 -1
View File
@@ -494,7 +494,12 @@ export default (router, tpl) => {
return res.reply({ body });
}
const isOnara = !!(cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara);
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const isOnara = cookieOnara !== null
? cookieOnara
: !!(req.session?.onara || (cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara));
if (mode === 'item' && isOnara) {
const itemHtml = tpl.render('ajax-item', data, req);
@@ -583,6 +588,7 @@ export default (router, tpl) => {
gridData.session = data.session;
gridData.is_onara_item = true;
gridData.onara = true;
gridData.onara_item_html = itemHtml;
gridData.item = data.item;
gridData.page_meta = data.page_meta;
+132 -11
View File
@@ -207,19 +207,35 @@ export const handleUpload = async (req, res, self) => {
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
const is_oc = (parts.is_oc === true || parts.is_oc === 'true' || parts.is_oc === '1');
const isAlbumRequested = (parts.is_album === true || parts.is_album === 'true' || parts.is_album === '1') || (rawFiles.length > 1 && !parts.is_shitpost);
const is_album = isAlbumRequested && rawFiles.length > 1;
// Collect URL items submitted as subf0ck_url_{i} fields (e.g. YouTube links in album mode)
const albumUrlItems = [];
for (let i = 0; i < 101; i++) {
const urlVal = typeof parts[`subf0ck_url_${i}`] === 'string' ? parts[`subf0ck_url_${i}`].trim() : null;
if (urlVal && /^https?:\/\//i.test(urlVal)) {
albumUrlItems.push({ index: i, url: urlVal });
}
}
const totalAlbumItems = rawFiles.length + albumUrlItems.length;
const isAlbumRequested = (parts.is_album === true || parts.is_album === 'true' || parts.is_album === '1') || (totalAlbumItems > 1 && !parts.is_shitpost);
const is_album = isAlbumRequested && totalAlbumItems > 1;
const is_shitpost = !is_album && ((parts.is_shitpost === true || parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true);
const maxAlbumItems = cfg.websrv?.max_album_items || cfg.websrv?.max_album_images || 100;
if (is_album && rawFiles.length > maxAlbumItems) {
if (is_album && totalAlbumItems > maxAlbumItems) {
return sendJson(res, { success: false, msg: `Album exceeds maximum limit of ${maxAlbumItems} items` }, 400);
}
if (!file && !inputUrl) {
if (!file && !inputUrl && albumUrlItems.length === 0) {
return sendJson(res, { success: false, msg: 'No file or URL provided' }, 400);
}
// For URL-only albums: use the first URL as the primary inputUrl
if (!file && !inputUrl && albumUrlItems.length > 0) {
inputUrl = albumUrlItems[0].url;
}
if (inputUrl && cfg.websrv.web_url_upload === false) {
return sendJson(res, { success: false, msg: 'URL uploads are disabled' }, 403);
}
@@ -316,7 +332,7 @@ export const handleUpload = async (req, res, self) => {
}
}
if (is_album) {
if (is_album && rawFiles.length > 0) {
for (let i = 0; i < rawFiles.length; i++) {
const f = rawFiles[i];
if (!f || !f.data || f.data.length === 0) {
@@ -464,14 +480,70 @@ export const handleUpload = async (req, res, self) => {
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility,
is_album: false,
album_count: 0
is_album: !!is_album,
album_count: is_album ? totalAlbumItems : 0
})})`;
} catch (err) {
console.error('[UPLOAD] YouTube new_item notify failed:', err);
}
}
// If this is a URL-only album, insert cover into album_items and process remaining URL sub-items
if (is_album && albumUrlItems.length > 0) {
const coverSubSlug = lib.generateSlug(11);
await db`
INSERT INTO album_items ${db({
item_id: itemid,
dest: filename,
mime: 'video/youtube',
size: 0,
checksum: `yt_${videoId}_cover_${Date.now()}`,
phash: null,
width: null,
height: null,
order_index: 0,
slug: coverSubSlug
}, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
`;
// Update the parent item to mark it as an album
await db`UPDATE items SET is_album = true, album_count = ${totalAlbumItems} WHERE id = ${itemid}`;
const ytRegexAlbum = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
for (let ui = 1; ui < albumUrlItems.length; ui++) {
const urlItem = albumUrlItems[ui];
const subYtMatch = urlItem.url.match(ytRegexAlbum);
if (!subYtMatch) continue;
const subVideoId = subYtMatch[1];
const subYtUrl = `https://www.youtube.com/watch?v=${subVideoId}`;
const subYtFilename = `yt:${subVideoId}`;
const subYtSlug = lib.generateSlug(11);
const insertedYtSub = await db`
INSERT INTO album_items ${db({
item_id: itemid,
dest: subYtFilename,
mime: 'video/youtube',
size: 0,
checksum: `yt_${subVideoId}_${Date.now()}_${ui}`,
phash: null,
width: null,
height: null,
order_index: ui,
slug: subYtSlug
}, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
RETURNING id
`;
const subYtItemId = insertedYtSub?.[0]?.id;
try {
await queue.genThumbnail(subYtFilename, 'video/youtube', subYtItemId || 0, subYtUrl, manualApproval);
} catch (_) {
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${subYtSlug}.webp`)]).catch(() => {});
}
}
}
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
const successMsg = manualApproval ? 'Upload successful! Your upload is pending admin approval.' : 'Upload successful! Your upload is now live.';
return sendJson(res, {
@@ -489,7 +561,9 @@ export const handleUpload = async (req, res, self) => {
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
display_name: req.session.display_name || null,
username: req.session.user
username: req.session.user,
is_album: !!is_album,
album_count: is_album ? totalAlbumItems : 0
});
}
@@ -803,7 +877,7 @@ export const handleUpload = async (req, res, self) => {
expires_at: targetExpiresAt,
uploader_ip: auditIp,
is_album: !!is_album,
album_count: is_album ? rawFiles.length : 0
album_count: is_album ? totalAlbumItems : 0
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at', 'uploader_ip', 'is_album', 'album_count')}
`;
@@ -973,6 +1047,44 @@ export const handleUpload = async (req, res, self) => {
console.warn(`[UPLOAD] Failed to generate thumbnail for album item ${subFilename}:`, tErr.message);
}
}
// Process URL sub-items (e.g. YouTube links added to the album)
const ytRegexAlbum = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
for (const urlItem of albumUrlItems) {
// Skip if this was used as the cover (index 0, already inserted via YouTube flow above)
if (!file && urlItem.url === inputUrl && urlItem === albumUrlItems[0]) continue;
const subYtMatch = urlItem.url.match(ytRegexAlbum);
if (!subYtMatch) continue;
const subVideoId = subYtMatch[1];
const subYtUrl = `https://www.youtube.com/watch?v=${subVideoId}`;
const subYtFilename = `yt:${subVideoId}`;
const subYtSlug = lib.generateSlug(11);
const insertedYtSub = await db`
INSERT INTO album_items ${db({
item_id: itemid,
dest: subYtFilename,
mime: 'video/youtube',
size: 0,
checksum: `yt_${subVideoId}_${Date.now()}_${urlItem.index}`,
phash: null,
width: null,
height: null,
order_index: rawFiles.length + albumUrlItems.indexOf(urlItem),
slug: subYtSlug
}, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
RETURNING id
`;
const subYtItemId = insertedYtSub?.[0]?.id;
// Generate placeholder thumbnail
try {
await queue.genThumbnail(subYtFilename, 'video/youtube', subYtItemId || 0, subYtUrl, manualApproval);
} catch (_) {
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${subYtSlug}.webp`)]).catch(() => {});
}
}
}
if (req.session?.is_anon) {
@@ -1067,6 +1179,15 @@ export const handleUpload = async (req, res, self) => {
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
`;
if (is_album) {
await db`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
SELECT id, ${ratingTagId}, ${req.session.id}
FROM album_items
WHERE item_id = ${itemid}
ON CONFLICT DO NOTHING
`.catch(() => {});
}
}
for (const tagName of tags) {
@@ -1231,7 +1352,7 @@ export const handleUpload = async (req, res, self) => {
slug: itemSlug,
visibility: targetVisibility,
is_album: !!is_album,
album_count: is_album ? rawFiles.length : 0
album_count: is_album ? totalAlbumItems : 0
})})`;
} catch (err) {
console.error('[UPLOAD] new_item notify failed:', err);
@@ -1305,7 +1426,7 @@ export const handleUpload = async (req, res, self) => {
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
is_album: !!is_album,
album_count: is_album ? rawFiles.length : 0,
album_count: is_album ? totalAlbumItems : 0,
display_name: req.session.display_name || null,
username: req.session.user
});