This commit is contained in:
2026-09-11 19:00:58 +02:00
parent 8d6edfef5e
commit d72eae7509
14 changed files with 681 additions and 373 deletions
+10 -2
View File
@@ -290,16 +290,24 @@ export default new class {
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}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
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
`;
let hasRating = false;
const cleanTags = [];
for (let t = 0; t < tags.length; t++) {
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
if (isRating) {
if (hasRating) continue;
hasRating = true;
}
tags[t].badge = this.getBadge(tags[t]);
if (!hasSession) {
delete tags[t].user;
delete tags[t].display_name;
}
cleanTags.push(tags[t]);
}
return tags;
return cleanTags;
};
getBadge(tagObj) {
if (tagObj.tag.startsWith(">"))
+1 -1
View File
@@ -138,7 +138,7 @@ export default (router, tpl) => {
const session = data.session;
const item = data.item;
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
+71 -40
View File
@@ -650,12 +650,28 @@ export default router => {
});
}
const rows = await db`
SELECT *
FROM "items"
WHERE id = ${data.itemid} AND active = true
LIMIT 1
`;
// Run item fetch + page lookup in parallel — saves one sequential DB round-trip
const [rows, itemPage] = await Promise.all([
db`
SELECT *
FROM "items"
WHERE id = ${data.itemid} AND active = true
LIMIT 1
`,
f0cklib.getItemPage({
targetItemId: data.itemid,
user, tag, hall, userHall, userHallOwner, mime,
fav: isFav,
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
}).catch(() => 1)
]);
const item = rows[0];
if (!item) {
@@ -685,7 +701,8 @@ export default router => {
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
dest: relativeDest,
url: directUrl,
direct_url: directUrl
direct_url: directUrl,
page: itemPage
}
});
});
@@ -1436,49 +1453,63 @@ export default router => {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const isOwner = item[0].username === req.session.user;
const isAdmin = req.session.admin || req.session.is_moderator;
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase());
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
const nsfl_id = cfg.nsfl_tag_id || 3;
const existingRating = await db`
SELECT tag_id FROM tags_assign
WHERE item_id = ${itemid} AND tag_id IN (1, 2, ${nsfl_id})
LIMIT 1
`;
const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
const nsfl_id = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
let newRatingId;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
newRatingId = 1;
} else if (reqRating === 'nsfw') {
newRatingId = 2;
} else if (reqRating === 'nsfl') {
newRatingId = nsfl_id;
} else {
// fallback to cycling
if (currentRatingId === 1) {
newRatingId = 2; // SFW -> NSFW
} else if (currentRatingId === 2) {
newRatingId = cfg.enable_nsfl ? nsfl_id : 1; // NSFW -> NSFL (if enabled) or SFW
} else {
newRatingId = 1; // NSFL or none -> SFW
}
}
let currentRatingId = null;
await db.begin(async sql => {
// Remove old rating tags
await sql`DELETE FROM tags_assign WHERE item_id = ${itemid} AND tag_id IN (1, 2, ${nsfl_id})`;
// Lock the item row exclusively so any concurrent rating update for this post MUST wait
await sql`SELECT id FROM items WHERE id = ${itemid} FOR UPDATE`;
const existingRating = await sql`
SELECT tag_id FROM tags_assign
WHERE item_id = ${itemid}
AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
ORDER BY tag_id DESC
LIMIT 1
`;
currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
newRatingId = 1;
} else if (reqRating === 'nsfw') {
newRatingId = 2;
} else if (reqRating === 'nsfl') {
newRatingId = nsfl_id;
} else {
// fallback to cycling
if (currentRatingId === 1) {
newRatingId = 2; // SFW -> NSFW
} else if (currentRatingId === 2) {
newRatingId = cfg.enable_nsfl ? nsfl_id : 1; // NSFW -> NSFL (if enabled) or SFW
} else {
newRatingId = 1; // NSFL or none -> SFW
}
}
// Remove ALL existing rating tags for this item atomically
await sql`
DELETE FROM tags_assign
WHERE item_id = ${itemid}
AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
// Insert new rating tag
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
`;
if (newRatingId > 0) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
`;
}
// Ensure blurred thumbnail exists
await queue.genBlurredThumbnail(itemid).catch(err => {
+72 -34
View File
@@ -97,53 +97,91 @@ export default router => {
});
});
group.put(/\/cycle-rating$/, lib.modAuth, async (req, res) => {
group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => {
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
const postid = +req.params.postid;
const nsflId = cfg.nsfl_tag_id || 3;
// Cycle: SFW(1) → NSFW(2) → NSFL(nsflId) → SFW(1); untagged items jump straight to SFW
const cycle = [1, 2, nsflId];
const currentTags = await lib.getTags(postid);
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
let nextTagId;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (1+1)%3 = 0 → SFW
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
const item = await db`
SELECT id, username, active, is_deleted
FROM items
WHERE id = ${postid} AND active = true AND is_deleted = false
LIMIT 1
`;
if (item.length === 0) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
try {
// Remove any existing rating tag
await db`DELETE FROM tags_assign WHERE item_id = ${postid} AND tag_id = ANY(ARRAY[1, 2, ${nsflId}]::int[])`;
if (nextTagId > 0) {
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
}
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase());
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
// Automatically generate/verify blurred thumbnail on cycle
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
await queue.genBlurredThumbnail(postid, false);
}
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
const cycle = [1, 2, nsflId];
let nextTagId;
let ratingTagId = 0;
try {
await db.begin(async sql => {
// Lock the item row exclusively
await sql`SELECT id FROM items WHERE id = ${postid} FOR UPDATE`;
const existingRating = await sql`
SELECT tag_id FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
ORDER BY tag_id DESC
LIMIT 1
`;
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (1+1)%3 = 0 → SFW
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
// Remove ALL existing rating tags for this item atomically
await sql`
DELETE FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
if (nextTagId > 0) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${postid}, ${nextTagId}, ${+req.session.id})
`;
}
// Automatically generate/verify blurred thumbnail on cycle
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
await queue.genBlurredThumbnail(postid, false);
}
});
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
const { label, cls } = labels[nextTagId];
const { label, cls } = labels[nextTagId] || { label: 'SFW', cls: 'sfw' };
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId });
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }).catch(() => {});
const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {});
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
} catch (err) {
console.error('[CYCLE_RATING_ERROR]', err);
return res.json({ success: false, msg: 'Failed to update rating' });
}
});
+1 -1
View File
@@ -371,7 +371,7 @@ export default (router, tpl) => {
// Is the current user a moderator/admin?
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
// Can the current user manage this item (owner, admin, or mod)?
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Is the item's MIME type suitable for metadata extraction?
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
+1 -1
View File
@@ -162,7 +162,7 @@ export default (router, tpl) => {
const session = data.session;
const item = data.item;
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';