This commit is contained in:
2026-09-28 20:40:33 +02:00
parent dde0a2d271
commit b96f188a59
5 changed files with 320 additions and 32 deletions
+133 -2
View File
@@ -10,6 +10,7 @@ import { getManualApproval } from "../settings.mjs";
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
import { setMotd } from "../motd.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import queue from "../queue.mjs";
export default (router, tpl) => {
@@ -69,7 +70,9 @@ export default (router, tpl) => {
const page = queuePage(req);
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
const pending = await db`
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
select i.id, i.mime, i.username, i.dest, i.title, i.stamp, i.size, i.width, i.height,
i.original_filename, i.is_album, i.album_count,
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
from "items" i
left join "tags_assign" ta on ta.item_id = i.id
left join "tags" t on t.id = ta.tag_id
@@ -79,12 +82,69 @@ export default (router, tpl) => {
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
`;
// Album pictures for the albums on this page (cover = order_index 0 uses the item's own files)
const albumIds = pending.filter(p => p.is_album).map(p => p.id);
const albumRows = albumIds.length ? await db`
select item_id, id, dest, mime, width, height, order_index
from album_items where item_id = any(${albumIds}::int[])
order by item_id, order_index asc
` : [];
const albumsById = new Map();
for (const r of albumRows) {
if (!albumsById.has(r.item_id)) albumsById.set(r.item_id, []);
albumsById.get(r.item_id).push(r);
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const mediaKind = (mime) => mime === 'video/youtube' ? 'youtube'
: mime?.startsWith('video') ? 'video'
: mime?.startsWith('image') ? 'image'
: mime?.startsWith('audio') ? 'audio'
: mime === 'application/pdf' ? 'pdf' : 'file';
const cards = processQueueItems(pending).map(p => {
const names = p.tags.map(t => t.normalized);
const rating = names.includes('nsfl') ? 'nsfl' : names.includes('nsfw') ? 'nsfw' : names.includes('sfw') ? 'sfw' : 'untagged';
// Main media (cover for albums)
const albumRows = p.is_album ? (albumsById.get(p.id) || []) : [];
const coverRow = albumRows.find(r => r.dest === p.dest) || albumRows.find(r => r.order_index === 0);
const media = [{
index: 1, aid: coverRow ? coverRow.id : null, kind: mediaKind(p.mime), mime: p.mime,
src: `/mod/pending/b/${p.dest}`, thumb: `/mod/pending/t/${p.id}.webp`,
width: p.width, height: p.height
}];
if (p.is_album) {
const subs = albumRows.filter(r => r !== coverRow);
for (const r of subs) {
media.push({
index: media.length + 1, aid: r.id, kind: mediaKind(r.mime), mime: r.mime,
src: `/mod/pending/b/${r.dest}`, thumb: `/mod/pending/t/${r.dest.replace(/\.[^.]+$/, '')}.webp`,
width: r.width, height: r.height
});
}
}
return {
...p,
rating,
kind: mediaKind(p.mime),
media,
media_json: JSON.stringify(media),
is_album_view: media.length > 1,
time_ago: p.stamp ? lib.timeAgo(new Date(p.stamp * 1000), req.lang) : '',
time_full: p.stamp ? new Date(p.stamp * 1000).toISOString() : '',
size_fmt: p.size ? lib.formatSize(p.size) : '',
dims: (p.width && p.height) ? `${p.width}×${p.height}` : ''
};
});
res.reply({
body: tpl.render('mod/approve', {
pending: processQueueItems(pending),
pending: cards,
total,
page,
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
enable_nsfl: !!cfg.enable_nsfl,
nsfl_tag_id: nsflId,
session: req.session,
tmp: null
}, req)
@@ -736,6 +796,77 @@ export default (router, tpl) => {
}
});
// Deny a single picture of a pending album. The rest stays in the queue; if the cover is denied the next
// picture becomes the cover, and with one picture left the album is degraded to a normal single post.
router.post(/^\/mod\/album-item\/deny\/?$/, lib.modAuth, async (req, res) => {
const itemId = +(req.post?.item_id || 0);
const subId = +(req.post?.album_item_id || 0);
const reason = (req.post?.reason || '').toString().trim();
if (!itemId || !subId) return jsonReply(res, 400, { success: false, msg: 'Missing item_id or album_item_id' });
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
const [item] = await db`select id, dest, mime, username, is_album from "items" where id = ${itemId} and active = false and is_deleted = false limit 1`;
if (!item || !item.is_album) return jsonReply(res, 404, { success: false, msg: 'Not a pending album' });
const subs = await db`
select id, dest, mime, size, checksum, phash, width, height, order_index
from album_items where item_id = ${itemId} order by order_index asc, id asc
`;
const target = subs.find(r => r.id === subId);
if (!target) return jsonReply(res, 404, { success: false, msg: 'Album item not found' });
if (subs.length <= 1) return jsonReply(res, 400, { success: false, msg: 'Only one item left, deny the whole post instead' });
const remaining = subs.filter(r => r.id !== subId);
const isCover = target.dest === item.dest;
const degraded = remaining.length === 1;
await db.begin(async sql => {
await sql`delete from album_items where id = ${subId}`;
for (let i = 0; i < remaining.length; i++) {
await sql`update album_items set order_index = ${i} where id = ${remaining[i].id}`;
}
if (isCover) {
const c = remaining[0];
await sql`
update "items" set dest = ${c.dest}, mime = ${c.mime}, size = ${c.size},
checksum = ${c.checksum}, phash = ${c.phash}, width = ${c.width}, height = ${c.height}
where id = ${itemId}
`;
}
if (degraded) {
await sql`delete from album_items where item_id = ${itemId}`;
await sql`update "items" set is_album = false, album_count = 0 where id = ${itemId}`;
} else {
await sql`update "items" set album_count = ${remaining.length} where id = ${itemId}`;
}
});
// Remove the denied picture's files from the pending folders
const base = target.dest.replace(/\.[^.]+$/, '');
await fs.unlink(path.join(cfg.paths.pending, 'b', target.dest)).catch(() => {});
await fs.unlink(path.join(cfg.paths.pending, 't', `${base}.webp`)).catch(() => {});
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${base}.webp`)).catch(() => {});
// New cover: regenerate the item's main + blurred thumbnail from it
if (isCover) {
const c = remaining[0];
try {
await queue.genThumbnail(c.dest, c.mime, itemId, null, true);
await queue.genBlurredThumbnail(itemId, true);
} catch (err) {
console.error('[MOD ALBUM DENY] Thumbnail regeneration failed:', err);
}
}
await audit.log(req.session.id, 'deny_album_item', 'item', itemId, {
filename: target.dest, album_item_id: subId, reason,
remaining: remaining.length, cover_changed: isCover, degraded,
...(await uploaderInfoFor(item.username))
});
return jsonReply(res, 200, { success: true, remaining: remaining.length, cover_changed: isCover, degraded });
});
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
// Restore a soft-deleted item: back to public, no "approved" notification or webhook