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.