update attachment logic
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,106 +2402,64 @@ 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);
|
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');
|
const nameEl = document.createElement('span');
|
||||||
nameEl.className = 'cf-filename';
|
nameEl.className = 'cf-filename';
|
||||||
nameEl.textContent = file.name;
|
nameEl.textContent = file.name;
|
||||||
previewItem.appendChild(nameEl);
|
previewItem.appendChild(nameEl);
|
||||||
|
|
||||||
|
const spoilerBtn = document.createElement('button');
|
||||||
|
spoilerBtn.className = 'cf-spoiler-btn';
|
||||||
|
spoilerBtn.title = 'Toggle spoiler';
|
||||||
|
spoilerBtn.innerHTML = '<i class="fa-solid fa-paperclip"></i><span class="spoiler-attach-badge">S</span>';
|
||||||
|
spoilerBtn.type = 'button';
|
||||||
|
previewItem.appendChild(spoilerBtn);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'cf-remove-btn';
|
||||||
|
removeBtn.title = removeLabel;
|
||||||
|
removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
previewItem.appendChild(removeBtn);
|
||||||
|
|
||||||
previewArea.appendChild(previewItem);
|
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 = '<i class="fa-solid fa-paperclip"></i><span class="spoiler-attach-badge">S</span>';
|
|
||||||
spoilerBtn.type = 'button';
|
|
||||||
previewItem.appendChild(spoilerBtn);
|
|
||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
|
||||||
removeBtn.className = 'cf-remove-btn';
|
|
||||||
removeBtn.title = removeLabel;
|
|
||||||
removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
|
||||||
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 = '';
|
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
|
||||||
// Fire-and-forget: tell the server to delete the orphaned upload record.
|
if (previewItem._objectUrl) URL.revokeObjectURL(previewItem._objectUrl);
|
||||||
// Only possible once upload has finished (fileId is set); silently ignored on failure —
|
// No server call needed — file was never uploaded
|
||||||
// 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');
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user