import db from "../sql.mjs"; import f0cklib from "../routeinc/f0cklib.mjs"; import cfg from "../config.mjs"; import lib from "../lib.mjs"; import audit from "../audit.mjs"; import { promises as fs } from "fs"; import { applyWordFilter } from "../wordfilter.mjs"; import path from "path"; export default (router, tpl) => { // Get comments for an item router.get(/\/api\/comments\/(?\d+)/, async (req, res) => { const itemId = req.params.itemid; const sort = req.url.qs?.sort || 'new'; // 'new' or 'old' // Require login unless comments are public if (!req.session && cfg.main.hide_comments_from_public) { return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, comments: [], require_login: true, user_id: null, is_admin: false }) }); } try { // Check locked status const item = await db`SELECT is_comments_locked FROM items WHERE id = ${itemId}`; const is_locked = item.length > 0 ? item[0].is_comments_locked : false; const comments = await f0cklib.getComments(itemId, sort, false); let is_subscribed = false; if (req.session) { const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId} AND is_subscribed = true`; if (sub.length > 0) is_subscribed = true; } // Fill in per-user poll votes if (req.session && cfg.websrv.enable_comment_polls) { const pollComments = comments.filter(c => c.poll); if (pollComments.length > 0) { const pollIds = pollComments.map(c => c.poll.id); try { const votes = await db` SELECT poll_id, option_id FROM comment_poll_votes WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id} `; const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id])); for (const c of pollComments) { if (c.poll) c.poll.user_vote_option_id = voteMap.get(c.poll.id) || null; } } catch (e) { /* graceful */ } } } // Transform for frontend if needed, or send as is return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, comments, is_subscribed, is_locked, user_id: req.session ? req.session.user : null, is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false }) }) } catch (err) { console.error(err); return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) }); } }); // Get a single comment by ID router.get(/\/api\/comment\/(?\d+)/, async (req, res) => { const id = req.params.id; // Require login unless comments are public if (!req.session && cfg.main.hide_comments_from_public) { return res.reply({ code: 401, headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: false, message: "Unauthorized" }) }); } try { const comment = await f0cklib.getComment(id); if (!comment) { return res.reply({ code: 404, headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: false, message: "Comment not found" }) }); } return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, comment }) }); } catch (err) { console.error(err); return res.reply({ code: 500, headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: false, message: "Database error" }) }); } }); // Browse User Comments router.get(/\/user\/(?[^\/]+)\/comments/, async (req, res) => { const user = decodeURIComponent(req.params.user); try { // Check if user exists and get ID + avatar const u = await db` SELECT "user".id, "user".user, user_options.avatar, user_options.avatar_file, user_options.username_color FROM "user" LEFT JOIN user_options ON "user".id = user_options.user_id WHERE "user".user ILIKE ${user} `; if (!u.length) { return res.reply({ code: 404, body: "User not found" }); } const userId = u[0].id; const sort = req.url.qs?.sort || 'new'; const page = +(req.url.qs?.page || 1); const limit = 20; const offset = (page - 1) * limit; const isJson = req.url.qs?.json === 'true'; if (!req.session || !req.session.user) { if (cfg.main.hide_comments_from_public) { if (isJson) { return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: false, require_login: true }) }); } else { return res.redirect('/login'); } } } const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or '); const excludedTags = req.session ? (req.session.excluded_tags || []) : []; /* */ // prioritize query mode (from AJAX) over session default let mode = req.mode; if (req.url.qs && req.url.qs.mode && (req.url.qs.mode === '0' || req.url.qs.mode === '1' || req.url.qs.mode === '2' || req.url.qs.mode === '3')) { mode = parseInt(req.url.qs.mode); } /* */ // Multi-rating cookie support (same logic as other routes) const ratingsRaw = req.cookies.ratings; const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null; // Build mode SQL — replace items.id alias with i.id used in the activity query const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id'); const comments = await db` SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug FROM comments c LEFT JOIN items i ON c.item_id = i.id WHERE c.user_id = ${userId} AND c.is_deleted = false AND i.active = true AND i.is_deleted = false AND ${db.unsafe(modequery)} ${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``} ORDER BY c.created_at DESC LIMIT ${limit} OFFSET ${offset} `; // Process mentions for user comments page too // Note: Since we need to modify 'comments', we do it before any map/HTML escaping // However, f0cklib.processMentions returns a new array with modified content. // But we actually need to do this BEFORE the existing 'processedComments' map which does HTML escaping. // Wait, f0cklib.processMentions adds Markdown links: [@user](/user/user). // HTML escaping later will break this: ["@user... // So we need to ensure formatting happens appropriately. // Actually, let's use processMentions here. // But notice below 'processedComments' logic manually escapes HTML and handles emojis. // If we add Markdown links now, 'escapeHtml' will destroy them. // We should probably rely on marked.js on the client side? // The client 'user_comments.js' uses marked.js! // So if we inject Markdown links, they will be rendered as links by marked.js. // BUT 'processedComments' escapes HTML. // Ideally, we should let marked handle everything or be careful. // Let's modify comments content in-place (or new array) before mapping const mentionsProcessed = await f0cklib.processMentions(comments); let processedComments = mentionsProcessed.map(c => { return { ...c, content: c.content }; }); // Fetch file attachments for all fetched comments if (processedComments.length > 0) { const commentIds = processedComments.map(c => c.id); try { const files = await db` SELECT id, comment_id, dest, mime, size, original_filename FROM comment_files WHERE comment_id = ANY(${commentIds}::int[]) ORDER BY id ASC `; const filesMap = new Map(); for (const f of files) { if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []); filesMap.get(f.comment_id).push(f); } for (const c of processedComments) { c.files = filesMap.get(c.id) || []; } } catch (e) { for (const c of processedComments) c.files = []; } // Fetch poll data for comments if (cfg.websrv.enable_comment_polls) { try { const commentIds = processedComments.map(c => c.id); const pollRows = await db` SELECT cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at, COALESCE(cp.is_anonymous, true) as is_anonymous, json_agg( json_build_object( 'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order, 'vote_count', COALESCE(vote_counts.cnt, 0) ) ORDER BY cpo.sort_order ASC, cpo.id ASC ) AS options, COALESCE(SUM(vote_counts.cnt), 0)::int AS total_votes FROM comment_polls cp JOIN comment_poll_options cpo ON cpo.poll_id = cp.id LEFT JOIN ( SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id ) vote_counts ON vote_counts.option_id = cpo.id WHERE cp.comment_id = ANY(${commentIds}::int[]) GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous `; // For non-anonymous polls, fetch voter names const nonAnonIds = pollRows.filter(p => !p.is_anonymous).map(p => p.poll_id); let votersByOption = new Map(); if (nonAnonIds.length > 0) { const voterRows = await db` SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file FROM comment_poll_votes cpv JOIN public."user" u ON u.id = cpv.user_id LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id WHERE cpv.poll_id = ANY(${nonAnonIds}::int[]) `; for (const v of voterRows) { if (!votersByOption.has(v.option_id)) votersByOption.set(v.option_id, []); votersByOption.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file }); } } const pollMap = new Map(); for (const p of pollRows) { const options = p.is_anonymous ? p.options : p.options.map(o => ({ ...o, voters: votersByOption.get(o.id) || [] })); pollMap.set(p.comment_id, { id: p.poll_id, question: p.question, expires_at: p.expires_at, is_anonymous: p.is_anonymous, options, total_votes: parseInt(p.total_votes) || 0, user_vote_option_id: null }); } // Fill in per-user poll votes if logged in if (req.session && pollRows.length > 0) { const pollIds = pollRows.map(p => p.poll_id); try { const votes = await db` SELECT poll_id, option_id FROM comment_poll_votes WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id} `; const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id])); for (const [comment_id, poll] of pollMap.entries()) { poll.user_vote_option_id = voteMap.get(poll.id) || null; } } catch (e) { /* graceful */ } } for (const c of processedComments) { c.poll = pollMap.get(c.id) || null; } } catch (e) { console.error('[USER_COMMENTS] Poll fetch error:', e.message); for (const c of processedComments) c.poll = null; } } else { for (const c of processedComments) c.poll = null; } } if (isJson) { return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, comments: processedComments, user: u[0] }) }); } const data = { user: u[0], comments: processedComments, hidePagination: true, tmp: null // for header/footer }; if (req.headers['x-requested-with'] === 'XMLHttpRequest') { return res.reply({ body: tpl.render('comments_user-partial', data, req) }); } return res.reply({ body: tpl.render('comments_user', data, req) }); } catch (e) { console.error(e); return res.reply({ code: 500, body: "Error" }); } }); // In-memory rate limiter for comment posting. // Tracks timestamps of recent posts per user_id in a sliding window. // Map: userId -> number[] (unix ms timestamps) const commentRateLimiter = new Map(); const COMMENT_RATE_LIMIT = 25; // max comments const COMMENT_RATE_WINDOW = 60_000; // per 60 seconds const isCommentRateLimited = (userId) => { const now = Date.now(); const windowStart = now - COMMENT_RATE_WINDOW; const timestamps = (commentRateLimiter.get(userId) || []).filter(t => t > windowStart); if (timestamps.length >= COMMENT_RATE_LIMIT) return true; timestamps.push(now); commentRateLimiter.set(userId, timestamps); // Prune entries for users inactive for > 5 minutes to avoid unbounded growth if (commentRateLimiter.size > 5000) { const pruneWindow = now - 300_000; for (const [uid, ts] of commentRateLimiter) { if (!ts.some(t => t > pruneWindow)) commentRateLimiter.delete(uid); } } return false; }; // Post a comment router.post('/api/comments', async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) }); // Rate limit regular users (admins and mods are exempt) if (!req.session.admin && !req.session.is_moderator) { if (isCommentRateLimited(req.session.id)) { return res.reply({ code: 429, body: JSON.stringify({ success: false, message: "You're posting too fast. Please slow down." }) }); } } if (cfg.main.development) console.log("DEBUG: POST /api/comments"); // Use standard framework parsing const body = req.post || {}; const item_id = parseInt(body.item_id, 10); const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null; let content = body.content || ''; content = await applyWordFilter(content); const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time))) ? parseFloat(body.video_time) : null; if (cfg.main.development) console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) }); const fileIdsRaw = body.file_ids || ''; const fileIds = fileIdsRaw ? fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) : []; const hasPoll = body.has_poll === '1' || body.has_poll === 'true'; if ((!content || !content.trim()) && fileIds.length === 0 && !hasPoll) { return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) }); } const maxLen = cfg.main.comment_max_length; if (maxLen !== null && maxLen !== undefined && content.length > maxLen) { return res.reply({ code: 400, body: JSON.stringify({ success: false, message: `Comment too long (max ${maxLen} characters)` }) }); } try { // Check if thread is locked (admins and mods can still post) if (!req.session.admin && !req.session.is_moderator) { const lockCheck = await db`SELECT COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${item_id}`; if (lockCheck.length > 0 && lockCheck[0].is_locked) { return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "This thread is locked" }) }); } } const insertData = { item_id, user_id: req.session.id, parent_id: parent_id || null, content: content || '' }; if (video_time !== null) insertData.video_time = video_time; const newComment = await db` INSERT INTO comments ${db(insertData)} RETURNING id, created_at, video_time `; const commentId = parseInt(newComment[0].id, 10); // Link uploaded files to this comment (if any) let activityFiles = []; const fileIdsRaw = body.file_ids || ''; if (fileIdsRaw) { const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0); if (fileIds.length > 0) { try { // Only link files that belong to this user and aren't already linked await db` UPDATE comment_files SET comment_id = ${commentId} WHERE id = ANY(${fileIds}::int[]) AND user_id = ${req.session.id} AND comment_id IS NULL `; // Fetch the linked files to send with live notification and post response activityFiles = await db` SELECT id, comment_id, dest, mime, size, original_filename FROM comment_files WHERE comment_id = ${commentId} ORDER BY id ASC `; } catch (err) { console.error('[COMMENTS] Failed to link files to comment:', err); } } } // Notify Subscribers (excluding the author) // 1. Get subscribers (active only) const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id} AND is_subscribed = true`; // Mentions Logic: Parse content for @username and [@User Name] (space-containing names). // Strip spoiler wrappers first so mentions inside [spoiler]...[/spoiler] are visible. const strippedContent = content.replace(/\[spoiler\]/gi, '').replace(/\[\/spoiler\]/gi, ''); const mentionRegex = /(? (m[1] || m[2]).trim()))]; const lowerNames = mentionedNames.map(n => n.toLowerCase()); let mentionedUsers = []; if (lowerNames.length > 0) { // Fetch IDs via login column (lowercase) mentionedUsers = await db`SELECT id, user FROM "user" WHERE login IN ${db(lowerNames)}`; } // 2. Get parent author let parentAuthor = []; if (parent_id) { parentAuthor = await db`SELECT user_id FROM comments WHERE id = ${parent_id}`; } // 3. Prepare notifications with priority: Mention > Reply > Subscription // Use a Map to ensure one notification per user const notificationsMap = new Map(); // UserId -> { type, ... } // A. Mentions mentionedUsers.forEach(u => { if (u.id !== req.session.id) { notificationsMap.set(u.id, 'mention'); } }); // B. Reply (Parent Author) if (parentAuthor.length > 0) { const pid = parentAuthor[0].user_id; // Only if not already mentioned if (pid !== req.session.id && !notificationsMap.has(pid)) { notificationsMap.set(pid, 'comment_reply'); } } // C. Subscribers const parentUserId = parentAuthor.length > 0 ? parentAuthor[0].user_id : -1; // Get uploader ID to distinguish notification type const itemInfo = await db` SELECT u.id as uploader_id FROM items i JOIN "user" u ON (i.username ILIKE u.login OR i.username ILIKE u.user) WHERE i.id = ${item_id} LIMIT 1 `; const uploaderId = itemInfo.length > 0 ? itemInfo[0].uploader_id : null; subscribers.forEach(s => { // If not self, and not already notified (as mention or reply) if (s.user_id !== req.session.id && !notificationsMap.has(s.user_id)) { // Use specialized type for uploader const type = (uploaderId && s.user_id === uploaderId) ? 'upload_comment' : 'subscription'; notificationsMap.set(s.user_id, type); } }); // 4. Batch insert non-bundleable, handle bundleable separately const bundleable = []; const nonBundleable = []; for (const [uid, type] of notificationsMap.entries()) { const notif = { user_id: uid, type: type, item_id: item_id, reference_id: commentId }; if (type === 'upload_comment') { bundleable.push(notif); } else { nonBundleable.push(notif); } } if (nonBundleable.length > 0) { await db`INSERT INTO notifications ${db(nonBundleable)}`; } for (const n of bundleable) { // Try to update existing unread notification for this item/user/type const updated = await db` UPDATE notifications SET created_at = NOW(), reference_id = ${n.reference_id} WHERE user_id = ${n.user_id} AND item_id = ${n.item_id} AND type = 'upload_comment' AND is_read = false RETURNING id `; if (updated.length === 0) { await db`INSERT INTO notifications ${db(n)}`; } } // Notify for live updates // Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here) const itemQuery = await db` SELECT i.slug, i.xd_score, (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id FROM items i WHERE i.id = ${item_id} `; const xdRow = itemQuery[0]; const ratingTagId = itemQuery[0]?.rating_tag_id; let ratingLabel = '?'; let ratingClass = 'untagged'; if (ratingTagId == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; } else if (ratingTagId == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; } else if (ratingTagId == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; } // Truncate body to 500 chars: PostgreSQL NOTIFY has an 8000-byte hard limit. // Large comments would silently drop the notification. The client fetches // the full content via _silentSync; the NOTIFY only needs to trigger the update. const notifyBody = content.length > 500 ? content.substring(0, 500) + '…' : content; const livePayload = { type: 'comment', id: commentId, item_id: item_id, item_slug: itemQuery[0]?.slug || null, parent_id: parent_id || null, body: notifyBody, username: req.session.user, user_id: req.session.id, avatar: req.session.avatar, avatar_file: req.session.avatar_file, created_at: new Date().toISOString(), username_color: req.session.username_color, display_name: req.session.display_name || null, xd_score: xdRow?.xd_score ?? null, video_time: newComment[0]?.video_time ?? null, files: activityFiles }; // 1. Thread live update db.notify('comments', JSON.stringify(livePayload)); // 2. Sidebar activity update // Compute is_long using the full content (not the truncated notifyBody) so the // sidebar renders the correct clamped state immediately on first paint. const activityIsLong = content.length > 120 || content.split('\n').length > 2 || activityFiles.length > 0 || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(content); db.notify('activity', JSON.stringify({ user_id: req.session.id, item_id: item_id, type: 'comment', body: notifyBody, id: commentId, item_rating_class: ratingClass, item_rating_label: ratingLabel, avatar: req.session.avatar, avatar_file: req.session.avatar_file, username: req.session.user, username_color: req.session.username_color, display_name: req.session.display_name || null, files: activityFiles, is_long: activityIsLong })); // Automatically subscribe user to the thread const subResult = await db` INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${item_id}) ON CONFLICT (user_id, item_id) DO NOTHING RETURNING 1 `; const is_new_subscription = subResult.length > 0; return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, comment: { ...newComment[0], content, files: activityFiles }, xd_score: xdRow?.xd_score ?? null, is_new_subscription }) }); } catch (err) { console.error(err); return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) }); } }); // Subscribe toggle router.post(/\/api\/subscribe\/(?\d+)/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); const itemId = req.params.itemid; try { const existing = await db` SELECT is_subscribed FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId} `; let subscribed = false; if (existing.length > 0) { subscribed = !existing[0].is_subscribed; await db`UPDATE comment_subscriptions SET is_subscribed = ${subscribed} WHERE user_id = ${req.session.id} AND item_id = ${itemId}`; } else { await db`INSERT INTO comment_subscriptions (user_id, item_id, is_subscribed) VALUES (${req.session.id}, ${itemId}, true)`; subscribed = true; } return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, subscribed }) }); } catch (e) { return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // Delete comment router.post(/\/api\/comments\/(?\d+)\/delete/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); const commentId = req.params.id; if (cfg.main.development) console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`); try { const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`; if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) }); const { getAllowCommentDeletion } = await import("../settings.mjs"); const canDeleteOwn = getAllowCommentDeletion(); const isOwner = comment[0].user_id === req.session.id; if (!req.session.admin && !req.session.is_moderator) { if (!canDeleteOwn || !isOwner) { return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) }); } } // Log all deletions in audit log const reason = (req.post && req.post.reason) ? req.post.reason : (req.url.qs?.reason || 'No reason provided'); await audit.log(req.session.id, 'delete_comment', 'comment', commentId, { item_id: comment[0].item_id, reason: reason, old_content: comment[0].content }); // Handle attachments cleanup const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`; for (const f of files) { const otherRefs = await db`SELECT id FROM comment_files WHERE checksum = ${f.checksum} AND id != ${f.id}`; const textRefs = await db`SELECT id FROM comments WHERE content LIKE ${'%' + f.dest + '%'} AND id != ${commentId}`; const hasRefs = otherRefs.length > 0 || textRefs.length > 0; const filePath = path.join(cfg.paths.c, f.dest); const thumbPath = path.join(cfg.paths.t, `cf_${f.dest.split('.')[0]}.webp`); if (!hasRefs) { // Safe to delete from disk (last reference) await fs.unlink(filePath).catch(() => {}); await fs.unlink(thumbPath).catch(() => {}); } else { // There are other references. Only delete if it's a symlink AND not referenced by text! try { const stats = await fs.lstat(filePath); if (stats.isSymbolicLink() && textRefs.length === 0) { await fs.unlink(filePath).catch(() => {}); await fs.unlink(thumbPath).catch(() => {}); } } catch (e) { console.error(`[DELETE_COMMENT] Failed to check stats for ${f.dest}:`, e.message); } } // Delete record from DB await db`DELETE FROM comment_files WHERE id = ${f.id}`; } await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`; // Notify for live update db.notify('comments', JSON.stringify({ type: 'delete', item_id: comment[0].item_id, comment_id: commentId })); return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true }) }); } catch (e) { return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // Delete comment attachment (admin/mod only) router.post('/api/comments/attachment/delete', async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) }); const body = req.post || {}; const filename = body.filename; if (!filename) { return res.reply({ body: JSON.stringify({ success: false, message: "Missing filename" }) }); } try { const cleanFilename = filename.split('#')[0]; const file = await db`SELECT id, comment_id, dest FROM comment_files WHERE dest = ${cleanFilename}`; if (!file.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Attachment not found" }) }); let commentId = file[0].comment_id; let comment; if (!commentId) { // Try to find the comment by content search if not linked const commentsFound = await db`SELECT id, content, item_id FROM comments WHERE content LIKE ${'%' + cleanFilename + '%'}`; if (commentsFound.length > 0) { commentId = commentsFound[0].id; comment = commentsFound; } } else { comment = await db`SELECT id, content, item_id FROM comments WHERE id = ${commentId}`; } if (!commentId || !comment.length) { // File uploaded but not linked and not found in any comment content await db`DELETE FROM comment_files WHERE id = ${file[0].id}`; const filePath = path.join(cfg.paths.c, file[0].dest); await fs.unlink(filePath).catch(() => {}); return res.reply({ body: JSON.stringify({ success: true, message: "Orphaned attachment deleted" }) }); } const escapedFilename = cleanFilename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Find ALL comments containing this filename and update them const commentsToUpdate = await db`SELECT id, content FROM comments WHERE content LIKE ${'%' + cleanFilename + '%'}`; let finalContent = comment[0].content; for (const c of commentsToUpdate) { const originalContent = c.content; const domainRegex = `(?:https?:\\/\\/[^\\/\\s]+)?`; const updatedContent = originalContent .replace(new RegExp(`!?\\[[^\\]]*\\]\\(${domainRegex}/c/${escapedFilename}(?:#gif)?\\)`, 'g'), '[attachment removed]') .replace(new RegExp(`${domainRegex}/c/${escapedFilename}(?:#gif)?`, 'g'), '[attachment removed]'); if (updatedContent !== originalContent) { await db`UPDATE comments SET content = ${updatedContent}, updated_at = NOW() WHERE id = ${c.id}`; if (c.id === commentId) { finalContent = updatedContent; } // Notify for live update for each updated comment db.notify('comments', JSON.stringify({ type: 'edit', comment_id: c.id, content: updatedContent })); } } // Delete file record await db`DELETE FROM comment_files WHERE id = ${file[0].id}`; // Delete file from disk const filePath = path.join(cfg.paths.c, file[0].dest); await fs.unlink(filePath).catch(() => {}); // Also delete thumbnail if exists const thumbPath = path.join(cfg.paths.t, `cf_${filename.split('.')[0]}.webp`); await fs.unlink(thumbPath).catch(() => {}); // Log in audit log await audit.log(req.session.id, 'delete_attachment', 'comment', commentId, { filename: filename, old_content: comment[0].content, new_content: finalContent }); // Notify for live update db.notify('comments', JSON.stringify({ type: 'edit', item_id: comment[0].item_id, comment_id: commentId, content: finalContent })); return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true }) }); } catch (e) { console.error(e); return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // Edit comment (admin/mod only) router.post(/\/api\/comments\/(?\d+)\/edit/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) }); const commentId = req.params.id; const body = req.post || {}; let content = body.content; content = await applyWordFilter(content); if (!content || !content.trim()) { return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) }); } try { const comment = await db`SELECT id, user_id, item_id, content FROM comments WHERE id = ${commentId}`; if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) }); const oldContent = comment[0].content; await audit.log(req.session.id, 'edit_comment', 'comment', commentId, { item_id: comment[0].item_id, old_content: oldContent.substring(0, 2000), new_content: content.substring(0, 2000) }); await db`UPDATE comments SET content = ${content}, updated_at = NOW() WHERE id = ${commentId}`; // Notify for live update db.notify('comments', JSON.stringify({ type: 'edit', item_id: comment[0].item_id, comment_id: commentId, content: content })); return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true }) }); } catch (e) { console.error(e); return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // Toggle pin comment (admin/mod only) router.post(/\/api\/comments\/(?\d+)\/pin/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) }); const commentId = req.params.id; try { const comment = await db`SELECT id, COALESCE(is_pinned, false) as is_pinned, item_id FROM comments WHERE id = ${commentId}`; if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) }); const newPinned = !comment[0].is_pinned; await db`UPDATE comments SET is_pinned = ${newPinned} WHERE id = ${commentId}`; await audit.log(req.session.id, 'pin_comment', 'comment', commentId, { item_id: comment[0].item_id, is_pinned: newPinned }); return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, is_pinned: newPinned }) }); } catch (e) { console.error(e); return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // Toggle lock thread (admin/mod only) router.post(/\/api\/comments\/(?\d+)\/lock/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) }); const itemId = req.params.itemid; try { const item = await db`SELECT id, COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${itemId}`; if (!item.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) }); const newLocked = !item[0].is_locked; await db`UPDATE items SET is_comments_locked = ${newLocked} WHERE id = ${itemId}`; await audit.log(req.session.id, newLocked ? 'lock_thread' : 'unlock_thread', 'item', itemId); return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, is_locked: newLocked }) }); } catch (e) { console.error(e); return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // Recent Activity Page router.get(/\/activity\/?/, async (req, res) => { try { const page = +(req.url.qs?.page || 1); const limit = Math.min(+(req.url.qs?.limit || 50), 50); // Support explicit offset (used by sidebar infinite-scroll after a non-standard initial limit) const offset = req.url.qs?.offset !== undefined ? Math.max(0, +(req.url.qs.offset)) : (page - 1) * limit; /* */ // prioritize query mode (from AJAX) over session default let mode = req.mode; if (req.url.qs && req.url.qs.mode && (req.url.qs.mode === '0' || req.url.qs.mode === '1' || req.url.qs.mode === '2' || req.url.qs.mode === '3')) { mode = parseInt(req.url.qs.mode); } /* */ // Multi-rating cookie support (same logic as other routes) const ratingsRaw = req.cookies.ratings; const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null; // Build mode SQL — replace items.id alias with i.id used in the activity query const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id'); const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or '); const excludedTags = req.session ? (req.session.excluded_tags || []) : []; const comments = await db` SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug, i.dest as item_dest, (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name FROM comments c LEFT JOIN items i ON c.item_id = i.id LEFT JOIN "user" u ON c.user_id = u.id LEFT JOIN user_options uo ON u.id = uo.user_id WHERE c.is_deleted = false AND i.active = true AND i.is_deleted = false AND ${db.unsafe(modequery)} ${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``} ORDER BY c.created_at DESC LIMIT ${limit} OFFSET ${offset} `; // Fetch comment file attachments const filesMap = new Map(); if (comments.length > 0) { const commentIds = comments.map(c => c.id); try { const files = await db` SELECT id, comment_id, dest, mime, size, original_filename FROM comment_files WHERE comment_id = ANY(${commentIds}::int[]) ORDER BY id ASC `; for (const f of files) { if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []); filesMap.get(f.comment_id).push(f); } } catch (e) { console.error('[ACTIVITY] Failed to fetch comment files:', e); } } // Fetch poll data for these comments const pollMap = new Map(); if (comments.length > 0 && cfg.websrv.enable_comment_polls) { try { const commentIds = comments.map(c => c.id); const pollRows = await db` SELECT cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at, COALESCE(cp.is_anonymous, true) as is_anonymous, json_agg( json_build_object( 'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order, 'vote_count', COALESCE(vc.cnt, 0) ) ORDER BY cpo.sort_order ASC, cpo.id ASC ) AS options, COALESCE(SUM(vc.cnt), 0)::int AS total_votes FROM comment_polls cp JOIN comment_poll_options cpo ON cpo.poll_id = cp.id LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id WHERE cp.comment_id = ANY(${commentIds}::int[]) GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous `; for (const p of pollRows) { pollMap.set(p.comment_id, { id: p.poll_id, question: p.question, expires_at: p.expires_at, is_anonymous: p.is_anonymous, options: p.options, total_votes: parseInt(p.total_votes) || 0, user_vote_option_id: null }); } } catch (e) { console.error('[ACTIVITY] Failed to fetch polls:', e.message); } } const processedComments = comments.map(c => { let ratingLabel = '?'; let ratingClass = 'untagged'; if (c.rating_tag_id == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; } else if (c.rating_tag_id == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; } else if (c.rating_tag_id == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; } const commentContent = (c.content || '').trim(); const commentFiles = filesMap.get(c.id) || []; // Compute overflow hint: true if content is likely taller than the 80px clamp. // ~120 chars ≈ 2-3 wrapped lines in the sidebar; any newlines > 2 or file // attachments (images/video) will also push height above the limit. const isLong = commentContent.length > 120 || commentContent.split('\n').length > 2 || commentFiles.length > 0 || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(commentContent); return { ...c, content: commentContent, username_color: c.username_color, item_rating_class: ratingClass, item_rating_label: ratingLabel, files: commentFiles, poll: pollMap.get(c.id) || null, is_long: isLong // created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it }; }); if (req.url.qs?.json === 'true' || req.headers['x-requested-with'] === 'XMLHttpRequest') { return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }, body: JSON.stringify({ success: true, comments: processedComments, page, hasMore: processedComments.length === limit }) }); } // Standalone page no longer exists return res.reply({ code: 404, body: "Page not found" }); } catch (e) { console.error(e); return res.reply({ code: 500, body: "Error loading activity data" }); } }); // Subscribe to all own uploads router.post('/api/v2/user/subscribe-all-uploads', async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) }); try { const result = await db` INSERT INTO comment_subscriptions (user_id, item_id) SELECT ${req.session.id}, i.id FROM items i WHERE i.username ILIKE ${req.session.login} OR i.username ILIKE ${req.session.user} ON CONFLICT DO NOTHING `; res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, message: `Successfully subscribed to your uploads` }) }); } catch (err) { console.error('[API] Failed to subscribe to all uploads:', err); res.reply({ code: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: "Internal server error" }) }); } }); // ────────────────────────────────────────────────────────────────────────── // Poll creation — called after a comment is inserted (internal helper) // ────────────────────────────────────────────────────────────────────────── const createPollForComment = async (commentId, pollData) => { if (!cfg.websrv.enable_comment_polls) return null; const { question, options, is_anonymous } = pollData || {}; if (!question || !question.trim()) return null; if (!Array.isArray(options) || options.length < 2) return null; const cleanOptions = options.map(o => (typeof o === 'string' ? o : String(o)).trim()).filter(Boolean); if (cleanOptions.length < 2 || cleanOptions.length > 10) return null; const anonymous = is_anonymous !== false; // default true const [poll] = await db` INSERT INTO comment_polls (comment_id, question, is_anonymous) VALUES (${commentId}, ${question.trim()}, ${anonymous}) RETURNING id, is_anonymous `; const pollId = poll.id; for (let i = 0; i < cleanOptions.length; i++) { await db` INSERT INTO comment_poll_options (poll_id, text, sort_order) VALUES (${pollId}, ${cleanOptions[i]}, ${i}) `; } const optRows = await db`SELECT id, text, sort_order FROM comment_poll_options WHERE poll_id = ${pollId} ORDER BY sort_order ASC`; return { id: pollId, question: question.trim(), is_anonymous: poll.is_anonymous, options: optRows.map(o => ({ id: o.id, text: o.text, sort_order: o.sort_order, vote_count: 0, voters: [] })), total_votes: 0, user_vote_option_id: null }; }; // Patch POST /api/comments to support optional poll payload // We cannot re-define the same route, so we intercept via a pre-middleware trick. // Instead we add a dedicated endpoint that the frontend always uses for polls. // POST /api/polls/:commentId — attach a poll to an existing just-created comment // (frontend calls this immediately after posting the comment) router.post(/\/api\/polls\/attach\/(?\d+)/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Polls disabled' }) }); const commentId = req.params.commentId; const body = req.post || {}; // Verify this comment belongs to the logged-in user and has no poll yet const comment = await db`SELECT id, user_id FROM comments WHERE id = ${commentId} AND is_deleted = false LIMIT 1`; if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: 'Comment not found' }) }); if (comment[0].user_id !== req.session.id && !req.session.admin && !req.session.is_moderator) { return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) }); } const existing = await db`SELECT id FROM comment_polls WHERE comment_id = ${commentId} LIMIT 1`; if (existing.length) return res.reply({ code: 409, body: JSON.stringify({ success: false, message: 'Poll already exists' }) }); let pollData; try { pollData = typeof body.poll === 'string' ? JSON.parse(body.poll) : body.poll; } catch (e) { return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll JSON' }) }); } try { const poll = await createPollForComment(parseInt(commentId, 10), pollData); if (!poll) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll data (need question + 2-10 options)' }) }); return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, poll }) }); } catch (err) { console.error('[POLLS] createPollForComment error:', err); return res.reply({ code: 500, body: JSON.stringify({ success: false, message: 'Database error' }) }); } }); // GET /api/polls/:pollId — fetch poll with current user's vote router.get(/\/api\/polls\/(?\d+)/, async (req, res) => { if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) }); const pollId = req.params.pollId; try { const pollRows = await db` SELECT cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at, COALESCE(cp.is_anonymous, true) as is_anonymous, json_agg( json_build_object( 'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order, 'vote_count', COALESCE(vc.cnt, 0) ) ORDER BY cpo.sort_order ASC, cpo.id ASC ) AS options, COALESCE(SUM(vc.cnt), 0)::int AS total_votes FROM comment_polls cp JOIN comment_poll_options cpo ON cpo.poll_id = cp.id LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id WHERE cp.id = ${pollId} GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous `; if (!pollRows.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) }); const p = pollRows[0]; let userVoteOptionId = null; if (req.session) { const vote = await db`SELECT option_id FROM comment_poll_votes WHERE poll_id = ${pollId} AND user_id = ${req.session.id} LIMIT 1`; if (vote.length) userVoteOptionId = vote[0].option_id; } // If not anonymous, attach voter usernames to each option let options = p.options; if (!p.is_anonymous) { const voterRows = await db` SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file FROM comment_poll_votes cpv JOIN public."user" u ON u.id = cpv.user_id LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id WHERE cpv.poll_id = ${pollId} `; const voterMap = new Map(); for (const v of voterRows) { if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []); voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file }); } options = options.map(o => ({ ...o, voters: voterMap.get(o.id) || [] })); } return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, poll: { id: p.poll_id, comment_id: p.comment_id, question: p.question, expires_at: p.expires_at, is_anonymous: p.is_anonymous, options, total_votes: parseInt(p.total_votes) || 0, user_vote_option_id: userVoteOptionId } }) }); } catch (err) { console.error('[POLLS] GET error:', err); return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // POST /api/polls/:pollId/vote — cast or change vote router.post(/\/api\/polls\/(?\d+)\/vote/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) }); const pollId = req.params.pollId; const body = req.post || {}; const optionId = parseInt(body.option_id, 10); if (!optionId) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Missing option_id' }) }); try { // Verify option belongs to poll const opt = await db`SELECT id FROM comment_poll_options WHERE id = ${optionId} AND poll_id = ${pollId} LIMIT 1`; if (!opt.length) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid option' }) }); // Check expiry const poll = await db`SELECT expires_at FROM comment_polls WHERE id = ${pollId} LIMIT 1`; if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) }); if (poll[0].expires_at && new Date(poll[0].expires_at) < new Date()) { return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Poll has expired' }) }); } // Upsert vote (change allowed) — manual check avoids needing a unique constraint const existing = await db` SELECT poll_id FROM comment_poll_votes WHERE poll_id = ${pollId} AND user_id = ${req.session.id} LIMIT 1 `; if (existing.length) { await db` UPDATE comment_poll_votes SET option_id = ${optionId}, created_at = now() WHERE poll_id = ${pollId} AND user_id = ${req.session.id} `; } else { await db` INSERT INTO comment_poll_votes (poll_id, option_id, user_id) VALUES (${pollId}, ${optionId}, ${req.session.id}) `; } // Return updated tally const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`; const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true; const rows = await db` SELECT cpo.id, cpo.text, cpo.sort_order, COALESCE(vc.cnt, 0)::int AS vote_count FROM comment_poll_options cpo LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes WHERE poll_id = ${pollId} GROUP BY option_id) vc ON vc.option_id = cpo.id WHERE cpo.poll_id = ${pollId} ORDER BY cpo.sort_order ASC `; const totalVotes = rows.reduce((s, r) => s + r.vote_count, 0); let options = rows; if (!isAnon) { const voterRows = await db` SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file FROM comment_poll_votes cpv JOIN public."user" u ON u.id = cpv.user_id LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id WHERE cpv.poll_id = ${pollId} `; const voterMap = new Map(); for (const v of voterRows) { if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []); voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file }); } options = rows.map(o => ({ ...o, voters: voterMap.get(o.id) || [] })); } return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, is_anonymous: isAnon, options, total_votes: totalVotes, user_vote_option_id: optionId }) }); } catch (err) { console.error('[POLLS] vote error:', err); return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); // DELETE /api/polls/:pollId — admin/mod or creator can delete router.post(/\/api\/polls\/(?\d+)\/delete/, async (req, res) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) }); const pollId = req.params.pollId; try { const poll = await db` SELECT cp.id, cp.comment_id, c.user_id FROM comment_polls cp JOIN comments c ON c.id = cp.comment_id WHERE cp.id = ${pollId} LIMIT 1 `; if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) }); const isCreator = poll[0].user_id === req.session.id; if (!isCreator && !req.session.admin && !req.session.is_moderator) { return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) }); } await db`DELETE FROM comment_polls WHERE id = ${pollId}`; // Notify live update db.notify('comments', JSON.stringify({ type: 'poll_deleted', poll_id: pollId, comment_id: poll[0].comment_id })); return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) }); } catch (err) { console.error('[POLLS] delete error:', err); return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); return router; };