update attachment logic

This commit is contained in:
2026-06-12 01:48:04 +02:00
parent 7dcd7005e4
commit 7b599e3afa
3 changed files with 147 additions and 218 deletions

View File

@@ -2422,8 +2422,6 @@ body.layout-legacy #comments-container.faded-out {
.submit-comment .submit-spinner { .submit-comment .submit-spinner {
display: none; display: none;
position: absolute; position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
margin: 0; margin: 0;
} }

View File

@@ -2358,8 +2358,10 @@ class CommentSystem {
} }
}); });
// Single Click Listener for Everything // File input change: store the File object locally and show a preview using a
this.container.addEventListener('change', async (e) => { // 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; if (!e.target.matches('.comment-file-input')) return;
const fileInput = e.target; const fileInput = e.target;
const wrap = fileInput.closest('.comment-input'); 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'); if (window.flashMessage) window.flashMessage((i18n.file_too_large || 'File too large') + `: ${file.name}`, 3000, 'error');
continue; 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 // Use saved cursor position (set when attach btn was clicked, before file picker
// dismissed the keyboard on mobile causing selectionStart to become 0). // dismissed the keyboard on mobile causing selectionStart to become 0).
@@ -2412,64 +2402,40 @@ class CommentSystem {
wrap._savedCursorPos = null; wrap._savedCursorPos = null;
wrap._savedCursorEnd = 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 before = textarea.value.substring(0, savedPos);
const after = textarea.value.substring(savedEnd); const after = textarea.value.substring(savedEnd);
const placeholder = `[${uploadingText} ${file.name}]`;
const sep = before.length > 0 && !/\s$/.test(before) ? ' ' : ''; const sep = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
textarea.value = before + sep + placeholder + after; textarea.value = before + sep + placeholderToken + after;
// Show uploading preview // Build local preview (no server call yet)
let previewItem = null;
if (previewArea) { if (previewArea) {
previewItem = document.createElement('div'); const objectUrl = URL.createObjectURL(file);
previewItem.className = 'cf-preview-item cf-uploading'; const previewItem = document.createElement('div');
const spinner = document.createElement('i'); previewItem.className = 'cf-preview-item';
spinner.className = 'fa-solid fa-spinner fa-spin'; previewItem.dataset.placeholderToken = placeholderToken;
previewItem.appendChild(spinner);
const nameEl = document.createElement('span');
nameEl.className = 'cf-filename';
nameEl.textContent = file.name;
previewItem.appendChild(nameEl);
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.dataset.originalFilename = file.name;
previewItem.innerHTML = ''; 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 (fileData.mime.startsWith('image/')) { if (file.type.startsWith('image/')) {
const img = document.createElement('img'); const img = document.createElement('img');
img.src = rawUrl; img.src = objectUrl;
img.loading = 'lazy'; img.loading = 'lazy';
previewItem.appendChild(img); previewItem.appendChild(img);
} else if (fileData.mime.startsWith('video/')) { } else if (file.type.startsWith('video/')) {
const vid = document.createElement('video'); const vid = document.createElement('video');
vid.src = rawUrl; vid.src = objectUrl;
vid.muted = true; vid.muted = true;
vid.preload = 'metadata'; vid.preload = 'metadata';
previewItem.appendChild(vid); previewItem.appendChild(vid);
} else { } else {
const icon = document.createElement('i'); const icon = document.createElement('i');
icon.className = 'fa-solid fa-music'; icon.className = file.type.startsWith('audio/') ? 'fa-solid fa-music' : 'fa-solid fa-file';
previewItem.appendChild(icon); previewItem.appendChild(icon);
} }
@@ -2491,26 +2457,8 @@ class CommentSystem {
removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>'; removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
removeBtn.type = 'button'; removeBtn.type = 'button';
previewItem.appendChild(removeBtn); previewItem.appendChild(removeBtn);
}
} else { previewArea.appendChild(previewItem);
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 = ''; fileInput.value = '';
@@ -2561,66 +2509,37 @@ class CommentSystem {
return; 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'); const spoilerToggle = target.closest('.cf-spoiler-btn');
if (spoilerToggle) { if (spoilerToggle) {
const previewItem = spoilerToggle.closest('.cf-preview-item'); const previewItem = spoilerToggle.closest('.cf-preview-item');
if (previewItem) { if (previewItem) {
const rawUrl = previewItem.dataset.url; // Staged items use a placeholder token in the textarea instead of a real URL.
const wrap = previewItem.closest('.comment-input'); // Toggle a data attribute; the actual [spoiler] wrapping is applied at submit.
const textarea = wrap?.querySelector('textarea'); const isSpoiler = previewItem.classList.toggle('cf-is-spoiler');
if (textarea && rawUrl) { spoilerToggle.title = isSpoiler ? 'Remove spoiler' : 'Toggle spoiler';
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';
}
}
} }
return; 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')) { if (target.matches('.cf-remove-btn') || target.closest('.cf-remove-btn')) {
const previewItem = target.closest('.cf-preview-item'); const previewItem = target.closest('.cf-preview-item');
if (previewItem) { if (previewItem) {
const rawUrl = previewItem.dataset.url; const token = previewItem.dataset.placeholderToken;
if (rawUrl) { if (token) {
const wrap = previewItem.closest('.comment-input'); const wrap = previewItem.closest('.comment-input');
const textarea = wrap?.querySelector('textarea'); const textarea = wrap?.querySelector('textarea');
if (textarea) { if (textarea) {
// Build patterns for both plain URL and spoiler-wrapped URL const escaped = token.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
const escapedUrl = rawUrl.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); textarea.value = textarea.value
const spoilerPattern = new RegExp('\\n?\\[spoiler\\]' + escapedUrl + '\\[\\/spoiler\\]\\n?', 'i'); .replace(new RegExp('\\n?' + escaped + '\\n?'), '\n')
const plainPattern = new RegExp('\\n?' + escapedUrl + '\\n?'); .replace(/^\n|\n$/g, '');
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, '');
} }
} }
} // Revoke the local object URL to free memory
if (previewItem._objectUrl) URL.revokeObjectURL(previewItem._objectUrl);
// Fire-and-forget: tell the server to delete the orphaned upload record. // No server call needed — file was never uploaded
// 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(); previewItem.remove();
} }
return; return;
@@ -3156,24 +3075,14 @@ class CommentSystem {
const wrap = e.target.closest('.comment-input'); const wrap = e.target.closest('.comment-input');
const submitBtn = wrap.querySelector('.submit-comment'); const submitBtn = wrap.querySelector('.submit-comment');
const textarea = wrap.querySelector('textarea'); const textarea = wrap.querySelector('textarea');
const text = textarea.value.trim();
const parentId = wrap.dataset.parent || null; const parentId = wrap.dataset.parent || null;
// Collect file IDs and files from upload previews // Collect staged (not-yet-uploaded) files from preview items
const fileIds = []; const stagedItems = [];
const files = [];
const previewArea = wrap.querySelector('.comment-file-preview'); const previewArea = wrap.querySelector('.comment-file-preview');
if (previewArea) { if (previewArea) {
previewArea.querySelectorAll('.cf-preview-item').forEach(item => { previewArea.querySelectorAll('.cf-preview-item').forEach(item => {
if (item.dataset.fileId) { if (item._stagedFile) stagedItems.push(item);
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
});
}
}); });
} }
@@ -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 (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 // Start loading state
submitBtn.classList.add('loading'); submitBtn.classList.add('loading');

View File

@@ -184,23 +184,6 @@ export const handleCommentUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400); 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 allowedMimes = getAllowedCommentMimes();
const results = []; 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. * Generate thumbnail for a comment file.