From 7b599e3afac53904d9ea6f49203c24ea64cfb641 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 12 Jun 2026 01:48:04 +0200 Subject: [PATCH] update attachment logic --- public/s/css/f0ckm.css | 2 - public/s/js/comments.js | 309 ++++++++++++++++----------------- src/comment_upload_handler.mjs | 54 ------ 3 files changed, 147 insertions(+), 218 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index c61f90b..cdbb404 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -2422,8 +2422,6 @@ body.layout-legacy #comments-container.faded-out { .submit-comment .submit-spinner { display: none; position: absolute; - top: 50%; - left: 50%; transform: translate(-50%, -50%); margin: 0; } diff --git a/public/s/js/comments.js b/public/s/js/comments.js index 2d4a7b8..d9fc868 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -2358,8 +2358,10 @@ class CommentSystem { } }); - // Single Click Listener for Everything - this.container.addEventListener('change', async (e) => { + // File input change: store the File object locally and show a preview using a + // local object URL. The actual upload happens at submit time, so no server round- + // trip occurs here and there is nothing to orphan. + this.container.addEventListener('change', (e) => { if (!e.target.matches('.comment-file-input')) return; const fileInput = e.target; const wrap = fileInput.closest('.comment-input'); @@ -2392,18 +2394,6 @@ class CommentSystem { if (window.flashMessage) window.flashMessage((i18n.file_too_large || 'File too large') + `: ${file.name}`, 3000, 'error'); continue; } - const fd = new FormData(); - fd.append('file', file); - const csrf = session.csrf_token || ''; - const uploadingText = i18n.uploading_file || 'Uploading...'; - const submitBtn = wrap.querySelector('.submit-comment'); - - // Track pending uploads - wrap._pendingUploads = (wrap._pendingUploads || 0) + 1; - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.classList.add('uploading'); - } // Use saved cursor position (set when attach btn was clicked, before file picker // dismissed the keyboard on mobile causing selectionStart to become 0). @@ -2412,106 +2402,64 @@ class CommentSystem { wrap._savedCursorPos = null; wrap._savedCursorEnd = null; + // Insert a placeholder token so the URL ends up in the right spot in the text + // once we know the real dest after the server-side upload at submit time. + const placeholderToken = `[attachment:${file.name}]`; const before = textarea.value.substring(0, savedPos); const after = textarea.value.substring(savedEnd); - const placeholder = `[${uploadingText} ${file.name}]`; const sep = before.length > 0 && !/\s$/.test(before) ? ' ' : ''; - textarea.value = before + sep + placeholder + after; + textarea.value = before + sep + placeholderToken + after; - // Show uploading preview - let previewItem = null; + // Build local preview (no server call yet) if (previewArea) { - previewItem = document.createElement('div'); - previewItem.className = 'cf-preview-item cf-uploading'; - const spinner = document.createElement('i'); - spinner.className = 'fa-solid fa-spinner fa-spin'; - previewItem.appendChild(spinner); + const objectUrl = URL.createObjectURL(file); + const previewItem = document.createElement('div'); + previewItem.className = 'cf-preview-item'; + previewItem.dataset.placeholderToken = placeholderToken; + previewItem.dataset.originalFilename = file.name; + previewItem.dataset.mime = file.type || 'application/octet-stream'; + // Store the File object directly on the element for retrieval at submit time + previewItem._stagedFile = file; + previewItem._objectUrl = objectUrl; + + if (file.type.startsWith('image/')) { + const img = document.createElement('img'); + img.src = objectUrl; + img.loading = 'lazy'; + previewItem.appendChild(img); + } else if (file.type.startsWith('video/')) { + const vid = document.createElement('video'); + vid.src = objectUrl; + vid.muted = true; + vid.preload = 'metadata'; + previewItem.appendChild(vid); + } else { + const icon = document.createElement('i'); + icon.className = file.type.startsWith('audio/') ? 'fa-solid fa-music' : 'fa-solid fa-file'; + previewItem.appendChild(icon); + } + const nameEl = document.createElement('span'); nameEl.className = 'cf-filename'; nameEl.textContent = file.name; previewItem.appendChild(nameEl); + + const spoilerBtn = document.createElement('button'); + spoilerBtn.className = 'cf-spoiler-btn'; + spoilerBtn.title = 'Toggle spoiler'; + spoilerBtn.innerHTML = 'S'; + spoilerBtn.type = 'button'; + previewItem.appendChild(spoilerBtn); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'cf-remove-btn'; + removeBtn.title = removeLabel; + removeBtn.innerHTML = ''; + removeBtn.type = 'button'; + previewItem.appendChild(removeBtn); + previewArea.appendChild(previewItem); } - - try { - const res = await fetch('/api/v2/comments/upload', { - method: 'POST', - headers: { 'X-CSRF-Token': csrf }, - body: fd - }); - const json = await res.json(); - if (json.success && json.files && json.files.length > 0) { - const fileData = json.files[0]; - const rawUrl = `/c/${fileData.dest}${fileData.converted_gif ? '#gif' : ''}`; - const url = rawUrl; - textarea.value = textarea.value.replace(placeholder, url); - - // Update preview with actual thumbnail - if (previewItem) { - previewItem.classList.remove('cf-uploading'); - previewItem.dataset.url = rawUrl; - previewItem.dataset.fileId = fileData.id; - previewItem.dataset.dest = fileData.dest; - previewItem.dataset.mime = fileData.mime; - previewItem.dataset.originalFilename = file.name; - previewItem.innerHTML = ''; - - if (fileData.mime.startsWith('image/')) { - const img = document.createElement('img'); - img.src = rawUrl; - img.loading = 'lazy'; - previewItem.appendChild(img); - } else if (fileData.mime.startsWith('video/')) { - const vid = document.createElement('video'); - vid.src = rawUrl; - vid.muted = true; - vid.preload = 'metadata'; - previewItem.appendChild(vid); - } else { - const icon = document.createElement('i'); - icon.className = 'fa-solid fa-music'; - previewItem.appendChild(icon); - } - - const nameEl = document.createElement('span'); - nameEl.className = 'cf-filename'; - nameEl.textContent = file.name; - previewItem.appendChild(nameEl); - - const spoilerBtn = document.createElement('button'); - spoilerBtn.className = 'cf-spoiler-btn'; - spoilerBtn.title = 'Toggle spoiler'; - spoilerBtn.innerHTML = 'S'; - spoilerBtn.type = 'button'; - previewItem.appendChild(spoilerBtn); - - const removeBtn = document.createElement('button'); - removeBtn.className = 'cf-remove-btn'; - removeBtn.title = removeLabel; - removeBtn.innerHTML = ''; - removeBtn.type = 'button'; - previewItem.appendChild(removeBtn); - } - } else { - textarea.value = textarea.value.replace(placeholder, ''); - if (previewItem) previewItem.remove(); - if (window.flashMessage) window.flashMessage(json.msg || 'Upload error', 3000, 'error'); - } - } catch (err) { - textarea.value = textarea.value.replace(placeholder, ''); - if (previewItem) previewItem.remove(); - if (window.flashMessage) window.flashMessage('Upload failed: ' + err.message, 3000, 'error'); - } finally { - // Decrement pending uploads - wrap._pendingUploads = Math.max(0, (wrap._pendingUploads || 1) - 1); - if (wrap._pendingUploads === 0) { - const submitBtn = wrap.querySelector('.submit-comment'); - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.classList.remove('uploading'); - } - } - } } fileInput.value = ''; }); @@ -2561,66 +2509,37 @@ class CommentSystem { return; } - // Attach file as spoiler: toggle [spoiler] wrapping on a preview item + // Attach file as spoiler: toggle spoiler marker on a staged preview item const spoilerToggle = target.closest('.cf-spoiler-btn'); if (spoilerToggle) { const previewItem = spoilerToggle.closest('.cf-preview-item'); if (previewItem) { - const rawUrl = previewItem.dataset.url; - const wrap = previewItem.closest('.comment-input'); - const textarea = wrap?.querySelector('textarea'); - if (textarea && rawUrl) { - const escapedUrl = rawUrl.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); - const spoilerPattern = new RegExp('\\[spoiler\\]' + escapedUrl + '\\[\/spoiler\\]', 'i'); - if (spoilerPattern.test(textarea.value)) { - // Unwrap - textarea.value = textarea.value.replace(spoilerPattern, rawUrl); - previewItem.classList.remove('cf-is-spoiler'); - spoilerToggle.title = 'Toggle spoiler'; - } else { - // Wrap - textarea.value = textarea.value.replace(new RegExp(escapedUrl), `[spoiler]${rawUrl}[/spoiler]`); - previewItem.classList.add('cf-is-spoiler'); - spoilerToggle.title = 'Remove spoiler'; - } - } + // Staged items use a placeholder token in the textarea instead of a real URL. + // Toggle a data attribute; the actual [spoiler] wrapping is applied at submit. + const isSpoiler = previewItem.classList.toggle('cf-is-spoiler'); + spoilerToggle.title = isSpoiler ? 'Remove spoiler' : 'Toggle spoiler'; } return; } - // Remove file preview + strip URL from textarea + // Remove file preview + strip placeholder token from textarea if (target.matches('.cf-remove-btn') || target.closest('.cf-remove-btn')) { const previewItem = target.closest('.cf-preview-item'); if (previewItem) { - const rawUrl = previewItem.dataset.url; - if (rawUrl) { + const token = previewItem.dataset.placeholderToken; + if (token) { const wrap = previewItem.closest('.comment-input'); const textarea = wrap?.querySelector('textarea'); if (textarea) { - // Build patterns for both plain URL and spoiler-wrapped URL - const escapedUrl = rawUrl.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); - const spoilerPattern = new RegExp('\\n?\\[spoiler\\]' + escapedUrl + '\\[\\/spoiler\\]\\n?', 'i'); - const plainPattern = new RegExp('\\n?' + escapedUrl + '\\n?'); - if (spoilerPattern.test(textarea.value)) { - textarea.value = textarea.value.replace(spoilerPattern, '\n').replace(/^\n|\n$/g, ''); - } else { - textarea.value = textarea.value.replace(plainPattern, '\n').replace(/^\n|\n$/g, ''); - } + const escaped = token.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); + textarea.value = textarea.value + .replace(new RegExp('\\n?' + escaped + '\\n?'), '\n') + .replace(/^\n|\n$/g, ''); } } - - // 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(() => {}); - } - + // Revoke the local object URL to free memory + if (previewItem._objectUrl) URL.revokeObjectURL(previewItem._objectUrl); + // No server call needed — file was never uploaded previewItem.remove(); } return; @@ -3156,24 +3075,14 @@ class CommentSystem { const wrap = e.target.closest('.comment-input'); const submitBtn = wrap.querySelector('.submit-comment'); const textarea = wrap.querySelector('textarea'); - const text = textarea.value.trim(); const parentId = wrap.dataset.parent || null; - // Collect file IDs and files from upload previews - const fileIds = []; - const files = []; + // Collect staged (not-yet-uploaded) files from preview items + const stagedItems = []; const previewArea = wrap.querySelector('.comment-file-preview'); if (previewArea) { previewArea.querySelectorAll('.cf-preview-item').forEach(item => { - if (item.dataset.fileId) { - fileIds.push(item.dataset.fileId); - files.push({ - id: parseInt(item.dataset.fileId, 10), - dest: item.dataset.dest, - mime: item.dataset.mime, - original_filename: item.dataset.originalFilename - }); - } + if (item._stagedFile) stagedItems.push(item); }); } @@ -3191,9 +3100,85 @@ class CommentSystem { } } - if (!text && fileIds.length === 0 && !pollPayload) return; + // Read current textarea value (we'll patch placeholder tokens after upload) + let text = textarea.value.trim(); + + if (!text && stagedItems.length === 0 && !pollPayload) return; if (submitBtn.classList.contains('loading') || submitBtn.disabled) return; - if (wrap._pendingUploads > 0) return; + + // ── Upload all staged files now (at submit time) ─────────────────────── + const fileIds = []; + const files = []; + + if (stagedItems.length > 0) { + submitBtn.classList.add('loading'); + submitBtn.disabled = true; + textarea.disabled = true; + + const session = window.f0ckSession || {}; + const csrf = session.csrf_token || ''; + const i18n = window.f0ckI18n || {}; + + const uploadResults = await Promise.all(stagedItems.map(async (item) => { + const file = item._stagedFile; + const fd = new FormData(); + fd.append('file', file); + try { + const res = await fetch('/api/v2/comments/upload', { + method: 'POST', + headers: { 'X-CSRF-Token': csrf }, + body: fd + }); + const json = await res.json(); + if (json.success && json.files && json.files.length > 0) { + return { item, fileData: json.files[0] }; + } else { + if (window.flashMessage) window.flashMessage(json.msg || (i18n.upload_failed || 'Upload failed'), 3000, 'error'); + return { item, fileData: null }; + } + } catch (err) { + if (window.flashMessage) window.flashMessage((i18n.upload_failed || 'Upload failed') + ': ' + err.message, 3000, 'error'); + return { item, fileData: null }; + } + })); + + // Any upload that failed: abort the whole submit so the user can retry + const anyFailed = uploadResults.some(r => !r.fileData); + if (anyFailed) { + submitBtn.classList.remove('loading'); + submitBtn.disabled = false; + textarea.disabled = false; + return; + } + + // Patch placeholder tokens in the textarea with real URLs + let currentText = textarea.value; + for (const { item, fileData } of uploadResults) { + const token = item.dataset.placeholderToken; + const isSpoiler = item.classList.contains('cf-is-spoiler'); + const rawUrl = `/c/${fileData.dest}${fileData.converted_gif ? '#gif' : ''}`; + const replacement = isSpoiler ? `[spoiler]${rawUrl}[/spoiler]` : rawUrl; + if (token) currentText = currentText.replace(token, replacement); + + // Update preview item to reflect its uploaded state + item.dataset.url = rawUrl; + item.dataset.fileId = fileData.id; + item.dataset.dest = fileData.dest; + item.dataset.mime = fileData.mime; + delete item._stagedFile; + if (item._objectUrl) { URL.revokeObjectURL(item._objectUrl); delete item._objectUrl; } + + fileIds.push(String(fileData.id)); + files.push({ + id: fileData.id, + dest: fileData.dest, + mime: fileData.mime, + original_filename: item.dataset.originalFilename + }); + } + textarea.value = currentText; + text = currentText.trim(); + } // Start loading state submitBtn.classList.add('loading'); diff --git a/src/comment_upload_handler.mjs b/src/comment_upload_handler.mjs index 8233bb5..903d574 100644 --- a/src/comment_upload_handler.mjs +++ b/src/comment_upload_handler.mjs @@ -184,23 +184,6 @@ 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 = []; @@ -567,43 +550,6 @@ export const handleCommentUploadCancel = async (req, res, fileId) => { } }; -/** - * 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.