prevent comment attachment to be abused
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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_<uuid>.webp
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user