diff --git a/public/s/js/comments.js b/public/s/js/comments.js index 656cbcc..93ff609 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -2548,6 +2548,19 @@ class CommentSystem { } } } + + // Fire-and-forget: tell the server to delete the orphaned upload record. + // Only possible once upload has finished (fileId is set); silently ignored on failure — + // the server-side orphan sweep will clean up any leftovers after 1 hour. + const fileId = previewItem.dataset.fileId; + if (fileId) { + const csrf = (window.f0ckSession || {}).csrf_token || ''; + fetch(`/api/v2/comments/upload/${fileId}`, { + method: 'DELETE', + headers: csrf ? { 'X-CSRF-Token': csrf } : {} + }).catch(() => {}); + } + previewItem.remove(); } return; diff --git a/src/comment_upload_handler.mjs b/src/comment_upload_handler.mjs index 89a171e..73bfa34 100644 --- a/src/comment_upload_handler.mjs +++ b/src/comment_upload_handler.mjs @@ -181,6 +181,24 @@ export const handleCommentUpload = async (req, res) => { return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400); } + // Per-user cap on unlinked (staged) attachments. + // A normal user only ever has a handful of files queued up in their compose box at once. + // Capping at (max attachments per comment) × 3 gives plenty of headroom for legitimate + // multi-file workflows while blocking upload-and-abandon abuse. + const maxAttachmentsPerComment = cfg.websrv.fileupload_comments_max || 5; + const MAX_PENDING_PER_USER = maxAttachmentsPerComment * 3; + const pendingCount = await db` + SELECT COUNT(*) AS cnt FROM comment_files + WHERE user_id = ${req.session.id} + AND comment_id IS NULL + `; + if (parseInt(pendingCount[0].cnt, 10) + files.length > MAX_PENDING_PER_USER) { + return sendJson(res, { + success: false, + msg: `Too many staged attachments. Please post or remove existing uploads first.` + }, 429); + } + const allowedMimes = getAllowedCommentMimes(); const results = []; @@ -479,6 +497,111 @@ export const handleCommentUpload = async (req, res) => { } }; +/** + * DELETE /api/v2/comments/upload/:id + * Called by the client when the user removes a staged (not-yet-posted) attachment + * from the compose area. Only deletes if the row still has comment_id = NULL + * (i.e. it was never linked to a real comment) and belongs to the requesting user. + */ +export const handleCommentUploadCancel = async (req, res, fileId) => { + // Manual session lookup (same pattern as handleCommentUpload) + if (req.cookies?.session) { + try { + const user = await db` + select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".* + from "user_sessions" + left join "user" on "user".id = "user_sessions".user_id + left join "user_options" on "user_options".user_id = "user_sessions".user_id + where "user_sessions".session = ${lib.sha256(req.cookies.session)} + limit 1 + `; + if (user.length > 0) { + req.session = user[0]; + } + } catch (err) { + // Session lookup failed + } + } + + if (!req.session) { + return sendJson(res, { success: false, msg: 'Unauthorized' }, 401); + } + + const id = parseInt(fileId, 10); + if (!id || isNaN(id)) { + return sendJson(res, { success: false, msg: 'Invalid file ID' }, 400); + } + + try { + // Only allow deletion of own unlinked files + const rows = await db` + SELECT id, dest FROM comment_files + WHERE id = ${id} + AND user_id = ${req.session.id} + AND comment_id IS NULL + `; + + if (!rows.length) { + // Either doesn't exist, belongs to someone else, or already linked — silently OK + return sendJson(res, { success: true }); + } + + const { dest } = rows[0]; + await db`DELETE FROM comment_files WHERE id = ${id}`; + + // Delete file and thumbnail from disk + const filePath = path.join(cfg.paths.c, dest); + const uuid = dest.split('.')[0]; + const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`); + await fs.unlink(filePath).catch(() => {}); + await fs.unlink(thumbPath).catch(() => {}); + + console.log(`[COMMENT_UPLOAD] Cancelled (user-removed) attachment deleted: ${dest}`); + return sendJson(res, { success: true }); + } catch (err) { + console.error('[COMMENT_UPLOAD] Cancel error:', err); + return sendJson(res, { success: false, msg: 'Delete failed' }, 500); + } +}; + +/** + * Periodic cleanup: delete comment_files rows with comment_id IS NULL + * that are older than 90 seconds. These are attachments that were uploaded + * but the comment was never posted (e.g. user closed the tab). + */ +const ORPHAN_MAX_AGE_MS = 90 * 1000; // 90 seconds + +const sweepOrphanedCommentFiles = async () => { + try { + const cutoff = new Date(Date.now() - ORPHAN_MAX_AGE_MS).toISOString(); + const orphans = await db` + SELECT id, dest FROM comment_files + WHERE comment_id IS NULL + AND created_at < ${cutoff} + `; + + if (!orphans.length) return; + + console.log(`[COMMENT_UPLOAD] Sweeping ${orphans.length} orphaned attachment(s) older than 90 seconds`); + + for (const { id, dest } of orphans) { + const filePath = path.join(cfg.paths.c, dest); + const uuid = dest.split('.')[0]; + const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`); + await fs.unlink(filePath).catch(() => {}); + await fs.unlink(thumbPath).catch(() => {}); + await db`DELETE FROM comment_files WHERE id = ${id}`; + } + } catch (err) { + console.error('[COMMENT_UPLOAD] Orphan sweep error:', err); + } +}; + +// Run sweep every 30 seconds +setInterval(sweepOrphanedCommentFiles, 30 * 1000); +// Also run once shortly after boot to catch any pre-existing orphans +setTimeout(sweepOrphanedCommentFiles, 30 * 1000); + /** * Generate thumbnail for a comment file. * Outputs to /t/cf_.webp diff --git a/src/index.mjs b/src/index.mjs index 1942af3..4c37de5 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -16,7 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs"; import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs"; import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; -import { handleCommentUpload } from "./comment_upload_handler.mjs"; +import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion } from "./inc/settings.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; @@ -846,6 +846,12 @@ process.on('uncaughtException', err => { await handleCommentUpload(req, res); req.url.pathname = '/handled_comment_upload_bypass'; } + // DELETE /api/v2/comments/upload/:id — user cancels a staged attachment + const cancelMatch = req.url.pathname.match(/^\/api\/v2\/comments\/upload\/(\d+)$/); + if (req.method === 'DELETE' && cancelMatch) { + await handleCommentUploadCancel(req, res, cancelMatch[1]); + req.url.pathname = '/handled_comment_upload_cancel_bypass'; + } }); // Bypass middleware for DM encrypted attachment upload/download/delete