This commit is contained in:
2026-09-13 06:12:37 +02:00
parent 33c6d0e76d
commit 466d3c4f28
20 changed files with 2156 additions and 134 deletions
+2 -2
View File
@@ -174,8 +174,8 @@ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFin
const userId = userRows[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name)
VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous')
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name, use_alternative_infobox)
VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous', ${cfg.websrv.user_alternative_infobox !== false})
ON CONFLICT (user_id) DO NOTHING
`;
+15 -15
View File
@@ -18,16 +18,12 @@ const computeBaseMode = (mode, ratings, session) => {
let effMode = Number(mode ?? 0);
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
// For guest sessions, sanitize ratingsArr to only allow permitted ratings
// For guest sessions, strictly force SFW mode and SFW rating
let safeRatingsArr = ratingsArr;
if (!session && ratingsArr) {
const allowedRatings = ['sfw'];
if (cfg.websrv.public_nsfw) allowedRatings.push('nsfw');
if (cfg.websrv.public_untagged) allowedRatings.push('untagged');
safeRatingsArr = ratingsArr.filter(r => allowedRatings.includes(r));
if (safeRatingsArr.length === 0) {
return "1 = 0";
}
const isGuest = !session || !session.user;
if (isGuest) {
effMode = 0;
safeRatingsArr = ['sfw'];
} else if (isAnonSession(session)) {
const allowedModes = getAnonAllowedModes();
const canFilter = canAnonDo('filter');
@@ -35,10 +31,14 @@ const computeBaseMode = (mode, ratings, session) => {
if (!canFilter) {
safeRatingsArr = null;
effMode = 0;
} else if (safeRatingsArr) {
safeRatingsArr = safeRatingsArr.filter(r => allowedModes.includes(r));
if (safeRatingsArr.length === 0) {
return "1 = 0";
} else {
if (safeRatingsArr) {
safeRatingsArr = safeRatingsArr.filter(r => allowedModes.includes(r));
if (safeRatingsArr.length === 0) {
safeRatingsArr = (allowedModes.length === 1 && !allowedModes.includes('all'))
? [allowedModes[0]]
: (allowedModes.length < 5 ? [...allowedModes] : null);
}
}
}
@@ -84,11 +84,11 @@ const computeBaseMode = (mode, ratings, session) => {
}
} else if (effMode === 2) {
if (!cfg.websrv.public_untagged) {
baseMode = "1 = 0";
baseMode = "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (effMode === 1) {
if (!cfg.websrv.public_nsfw) {
baseMode = "1 = 0";
baseMode = "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (effMode === 4) {
baseMode = "1 = 0";
+6 -4
View File
@@ -34,10 +34,11 @@ export default (router, tpl) => {
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
const isGuest = !req.session || !req.session.user;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const reqMode = query.mode !== undefined ? +query.mode : req.mode;
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
const ratingsRaw = req.cookies.ratings;
const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null));
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
const data = await f0cklib.getf0ck({
@@ -256,10 +257,11 @@ export default (router, tpl) => {
query = req.url.qs || {};
}
const isGuest = !req.session || !req.session.user;
const page = parseInt(query.page) || 1;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const ratingsArr = isGuest ? ['sfw'] : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
const data = await f0cklib.getf0cks({
page: page,
@@ -269,7 +271,7 @@ export default (router, tpl) => {
userHall: query.userHall || null,
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode,
mode: isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode),
ratings: ratingsArr,
session: req.session,
user_id: req.session?.id,
+253
View File
@@ -9,6 +9,259 @@ import { logAnonActivity } from "../../anon_auth.mjs";
import { canAnonDo, isAnonSession } from "../../settings.mjs";
export default router => {
router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
const isModOrAdmin = !!(req.session?.admin || req.session?.is_moderator);
if (!isModOrAdmin) {
return res.json({ success: false, msg: 'Bulk selection is only available to administrators and moderators' }, 403);
}
const action = req.body?.action || req.post?.action || 'add';
const rawIds = req.body?.item_ids || req.post?.item_ids;
if (!rawIds || !Array.isArray(rawIds) || rawIds.length === 0) {
return res.json({ success: false, msg: 'No items selected' }, 400);
}
// Sanitize item IDs
const itemIds = [...new Set(rawIds.map(id => +id).filter(id => Number.isInteger(id) && id > 0))].slice(0, 500);
if (itemIds.length === 0) {
return res.json({ success: false, msg: 'Invalid item IDs' }, 400);
}
// 1. ADD TAGS
if (action === 'add') {
if (isAnonSession(req.session) && !canAnonDo('tag')) {
return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
}
let rawTags = req.body?.tags || req.post?.tags;
if (typeof rawTags === 'string') {
rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
}
if (!Array.isArray(rawTags) || rawTags.length === 0) {
return res.json({ success: false, msg: 'No tags provided' }, 400);
}
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
const validTags = [...new Set(
rawTags
.map(t => (typeof t === 'string' ? t.trim() : ''))
.filter(t => t.length > 0 && t.length <= 85 && !protectedTags.includes(t.toLowerCase()))
)];
if (validTags.length === 0) {
return res.json({ success: false, msg: 'Tags invalid or contain only reserved names' }, 400);
}
try {
const tagIds = [];
for (const tagname of validTags) {
let tagRow = await db`
SELECT id FROM "tags"
WHERE normalized = slugify(${tagname})
LIMIT 1
`;
let tagid = tagRow?.[0]?.id;
if (!tagid) {
const created = await db`
INSERT INTO "tags" ${db({ tag: tagname })}
RETURNING id
`;
tagid = created?.[0]?.id;
}
if (tagid) tagIds.push({ id: +tagid, name: tagname });
}
// Insert assignments
for (const postid of itemIds) {
for (const { id: tagid } of tagIds) {
try {
await db`
INSERT INTO "tags_assign" (tag_id, item_id, user_id)
VALUES (${tagid}, ${postid}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
} catch (assignErr) {
// Ignore duplicate errors if table has constraint
if (assignErr.code !== '23505') {
console.warn(`[BULK_TAG_ASSIGN_WARN] Item ${postid}, tag ${tagid}:`, assignErr.message);
}
}
}
}
await audit.log(req.session.id, 'bulk_add_tags', 'items', null, { item_ids: itemIds, tags: validTags }).catch(() => {});
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'bulk_tag',
targetId: itemIds[0],
details: { item_ids: itemIds, tags: validTags }
}).catch(() => {});
}
// Notify affected items asynchronously
(async () => {
for (const postid of itemIds) {
try {
const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
} catch {}
}
})().catch(() => {});
return res.json({
success: true,
count: itemIds.length,
tags: validTags,
msg: `Successfully added ${validTags.length} tag(s) to ${itemIds.length} item(s)`
});
} catch (err) {
console.error('[BULK_ADD_TAGS_ERROR]', err);
return res.json({ success: false, msg: 'Failed to add tags' }, 500);
}
}
// 2. REMOVE TAGS
if (action === 'remove') {
const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
let rawTags = req.body?.tags || req.post?.tags;
if (typeof rawTags === 'string') {
rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
}
if (!Array.isArray(rawTags) || rawTags.length === 0) {
return res.json({ success: false, msg: 'No tags provided to remove' }, 400);
}
try {
// Resolve tags to remove
const tagRows = await db`
SELECT id, tag, normalized FROM "tags"
WHERE normalized IN ${db(rawTags.map(t => t.trim().toLowerCase()))}
`;
if (tagRows.length === 0) {
return res.json({ success: false, msg: 'Tags not found' }, 404);
}
const tagIds = tagRows.map(r => r.id);
if (isModOrAdmin) {
await db`
DELETE FROM "tags_assign"
WHERE item_id IN ${db(itemIds)}
AND tag_id IN ${db(tagIds)}
`;
} else {
// Normal user can only remove tags from items they own
await db`
DELETE FROM "tags_assign"
WHERE item_id IN ${db(itemIds)}
AND tag_id IN ${db(tagIds)}
AND item_id IN (
SELECT id FROM items WHERE username = ${req.session.user}
)
`;
}
await audit.log(req.session.id, 'bulk_remove_tags', 'items', null, { item_ids: itemIds, tags: tagRows.map(r => r.tag) }).catch(() => {});
// Realtime notifications
(async () => {
for (const postid of itemIds) {
try {
const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
} catch {}
}
})().catch(() => {});
return res.json({
success: true,
count: itemIds.length,
msg: `Successfully removed tag(s) from ${itemIds.length} item(s)`
});
} catch (err) {
console.error('[BULK_REMOVE_TAGS_ERROR]', err);
return res.json({ success: false, msg: 'Failed to remove tags' }, 500);
}
}
// 3. SET RATING (sfw / nsfw / nsfl)
if (action === 'set_rating') {
if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
}
const rating = (req.body?.rating || req.post?.rating || '').toLowerCase();
if (!['sfw', 'nsfw', 'nsfl'].includes(rating)) {
return res.json({ success: false, msg: 'Invalid rating. Choose sfw, nsfw, or nsfl' }, 400);
}
const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
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 targetTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : nsflId);
try {
let eligibleIds = itemIds;
if (!isModOrAdmin) {
const ownedItems = await db`
SELECT id FROM items
WHERE id IN ${db(itemIds)}
AND username = ${req.session.user}
AND active = true AND is_deleted = false
`;
eligibleIds = ownedItems.map(r => r.id);
}
if (eligibleIds.length === 0) {
return res.json({ success: false, msg: 'You do not have permission to rate the selected items' }, 403);
}
await db.begin(async sql => {
// Delete existing rating tags
await sql`
DELETE FROM tags_assign
WHERE item_id IN ${sql(eligibleIds)}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
// Insert new rating tags
for (const id of eligibleIds) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${id}, ${targetTagId}, ${+req.session.id})
`;
}
});
await audit.log(req.session.id, 'bulk_set_rating', 'items', null, { item_ids: eligibleIds, rating }).catch(() => {});
// Queue blur thumbnail verification where needed
(async () => {
for (const id of eligibleIds) {
try {
const blurPath = path.join(cfg.paths.t, `${id}_blur.webp`);
await fs.promises.access(blurPath).catch(() => queue.genBlurredThumbnail(id, false));
const freshTags = await lib.getTags(id);
await db.notify('tags', JSON.stringify({ item_id: id, fresh: true, tags: freshTags }));
} catch {}
}
})().catch(() => {});
return res.json({
success: true,
count: eligibleIds.length,
rating,
msg: `Successfully set rating to ${rating.toUpperCase()} for ${eligibleIds.length} item(s)`
});
} catch (err) {
console.error('[BULK_SET_RATING_ERROR]', err);
return res.json({ success: false, msg: 'Failed to update rating' }, 500);
}
}
return res.json({ success: false, msg: 'Unknown bulk action' }, 400);
});
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
group.get(/$/, lib.loggedin, async (req, res) => {
// get tags
+13 -1
View File
@@ -645,7 +645,19 @@ export default (router, tpl) => {
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
const mode = modeMatch ? +modeMatch[1] : 0;
if (isAnonSession(req.session)) {
const isGuest = !req.session || !req.session.user;
if (isGuest) {
if (mode !== 0) {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
code: 403,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'Only SFW mode is allowed for guests' })
});
}
return res.redirect('/');
}
} else if (isAnonSession(req.session)) {
if (!canAnonDo('filter') || !canAnonMode(mode)) {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({