admin/mods can delete attachments
This commit is contained in:
@@ -7642,6 +7642,48 @@ video.autoplay-gif {
|
|||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-embed-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-delete-attachment-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #ff4d4d;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease-in-out, background 0.2s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-embed-wrap:hover .admin-delete-attachment-btn,
|
||||||
|
.video-embed-wrap:hover .admin-delete-attachment-btn,
|
||||||
|
.audio-embed-wrap:hover .admin-delete-attachment-btn {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-delete-attachment-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #ff6666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-delete-attachment-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-embed-wrap audio {
|
.audio-embed-wrap audio {
|
||||||
|
|||||||
@@ -1541,6 +1541,16 @@ class CommentSystem {
|
|||||||
return `<a href="${href}"${titleAttr}${isMention ? ' class="mention"' : ''}>${displayText}</a>${extraSuffix}`;
|
return `<a href="${href}"${titleAttr}${isMention ? ' class="mention"' : ''}>${displayText}</a>${extraSuffix}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renderer.image = (href, title, text) => {
|
||||||
|
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
|
||||||
|
const imgHtml = `<img src="${src}" alt="${text || ''}"${title ? ` title="${title}"` : ''} onerror="this.onerror=null; this.outerHTML='<span class=\\'broken-image-text\\'>[image not found]</span>';">`;
|
||||||
|
if (this.isAdmin && src && src.startsWith('/c/')) {
|
||||||
|
const filename = src.substring(3); // Remove '/c/'
|
||||||
|
return `<span class="image-embed-wrap">${imgHtml}<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[x]</button></span>`;
|
||||||
|
}
|
||||||
|
return imgHtml;
|
||||||
|
};
|
||||||
|
|
||||||
// Pre-compile regexes used in the loop
|
// Pre-compile regexes used in the loop
|
||||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const allowedHosts = [escapedSiteHost];
|
const allowedHosts = [escapedSiteHost];
|
||||||
@@ -1556,7 +1566,7 @@ class CommentSystem {
|
|||||||
// "Safe non-whitespace" — matches any \S except the start of an https?:// boundary.
|
// "Safe non-whitespace" — matches any \S except the start of an https?:// boundary.
|
||||||
// Prevents concatenated URLs (url1.webpurl2.webp) being consumed as one giant src.
|
// Prevents concatenated URLs (url1.webpurl2.webp) being consumed as one giant src.
|
||||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||||
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?(?:#gif)?))(?![\\)\\]])`, 'gi');
|
||||||
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
||||||
const rawAudioRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))`, 'gi');
|
const rawAudioRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))`, 'gi');
|
||||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
||||||
@@ -1674,16 +1684,26 @@ class CommentSystem {
|
|||||||
md = md.replace(videoEmbedRegex, (match, url) => {
|
md = md.replace(videoEmbedRegex, (match, url) => {
|
||||||
const isConvertedGif = url.endsWith('#gif');
|
const isConvertedGif = url.endsWith('#gif');
|
||||||
const cleanUrl = url.replace(/#gif$/, '');
|
const cleanUrl = url.replace(/#gif$/, '');
|
||||||
if (isConvertedGif) {
|
let deleteBtn = '';
|
||||||
return `<span class="video-embed-wrap"><video src="${cleanUrl}" class="autoplay-gif" loop muted playsinline preload="auto"></video></span>`;
|
if (this.isAdmin && cleanUrl.startsWith('/c/')) {
|
||||||
|
const filename = cleanUrl.substring(3);
|
||||||
|
deleteBtn = `<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[del attachment]</button>`;
|
||||||
}
|
}
|
||||||
return `<span class="video-embed-wrap"><video src="${cleanUrl}" controls loop muted playsinline preload="metadata"></video></span>`;
|
if (isConvertedGif) {
|
||||||
|
return `<span class="video-embed-wrap"><video src="${cleanUrl}" class="autoplay-gif" loop muted playsinline preload="auto"></video>${deleteBtn}</span>`;
|
||||||
|
}
|
||||||
|
return `<span class="video-embed-wrap"><video src="${cleanUrl}" controls loop muted playsinline preload="metadata"></video>${deleteBtn}</span>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Audio embed: replace anchor links pointing to audio files from allowed hosters with an audio player
|
// Audio embed: replace anchor links pointing to audio files from allowed hosters with an audio player
|
||||||
const audioEmbedRegex = new RegExp(`<a\\s[^>]*href="(${mediaDomainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]+\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))"[^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
|
const audioEmbedRegex = new RegExp(`<a\\s[^>]*href="(${mediaDomainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]+\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))"[^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
|
||||||
md = md.replace(audioEmbedRegex, (match, url) => {
|
md = md.replace(audioEmbedRegex, (match, url) => {
|
||||||
return `<span class="audio-embed-wrap"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
let deleteBtn = '';
|
||||||
|
if (this.isAdmin && url.startsWith('/c/')) {
|
||||||
|
const filename = url.substring(3);
|
||||||
|
deleteBtn = `<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[del attachment]</button>`;
|
||||||
|
}
|
||||||
|
return `<span class="audio-embed-wrap"><audio src="${url}" controls preload="metadata"></audio>${deleteBtn}</span>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
|
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
|
||||||
@@ -1749,7 +1769,7 @@ class CommentSystem {
|
|||||||
v.autoplay = true;
|
v.autoplay = true;
|
||||||
v.muted = true;
|
v.muted = true;
|
||||||
v.play().catch(() => {
|
v.play().catch(() => {
|
||||||
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
|
v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1887,7 +1907,7 @@ class CommentSystem {
|
|||||||
<a href="#c${comment.id}" class="comment-time timeago" title="${fullDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a>
|
<a href="#c${comment.id}" class="comment-time timeago" title="${fullDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>
|
<div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>
|
||||||
${this.renderCommentAttachments(comment.files)}
|
${this.renderCommentAttachments(comment.files, comment.content)}
|
||||||
<div class="comment-footer">
|
<div class="comment-footer">
|
||||||
<div class="comment-footer-right">
|
<div class="comment-footer-right">
|
||||||
<div class="comment-actions">
|
<div class="comment-actions">
|
||||||
@@ -1931,10 +1951,11 @@ class CommentSystem {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCommentAttachments(files) {
|
renderCommentAttachments(files, content = '') {
|
||||||
if (!files || files.length === 0) return '';
|
if (!files || files.length === 0) return '';
|
||||||
const items = files.map(f => {
|
const items = files.map(f => {
|
||||||
const url = `/c/${f.dest}`;
|
const url = `/c/${f.dest}`;
|
||||||
|
if (content.includes(url)) return ''; // Skip if already rendered in content
|
||||||
if (f.mime.startsWith('image/')) {
|
if (f.mime.startsWith('image/')) {
|
||||||
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" alt="${this.escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" alt="${this.escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
||||||
} else if (f.mime.startsWith('video/')) {
|
} else if (f.mime.startsWith('video/')) {
|
||||||
@@ -2427,15 +2448,64 @@ class CommentSystem {
|
|||||||
body: params
|
body: params
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
this.loadComments();
|
const commentEl = document.getElementById(`c${id}`);
|
||||||
} else {
|
if (commentEl) {
|
||||||
|
commentEl.classList.add('deleted');
|
||||||
|
const contentEl = commentEl.querySelector('.comment-content');
|
||||||
|
if (contentEl) {
|
||||||
|
contentEl.innerHTML = '<span class="deleted-msg">[deleted]</span>';
|
||||||
|
}
|
||||||
|
const actionsEl = commentEl.querySelector('.comment-actions');
|
||||||
|
if (actionsEl) {
|
||||||
|
actionsEl.innerHTML = ''; // Remove reply/quote/admin buttons
|
||||||
|
}
|
||||||
|
// Remove attachments if present
|
||||||
|
const attachmentsEl = commentEl.querySelector('.comment-attachments, .media-pills'); // Try common classes
|
||||||
|
if (attachmentsEl) attachmentsEl.remove();
|
||||||
|
} else {
|
||||||
|
this.loadComments();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
throw new Error(json.message || 'Failed to delete');
|
throw new Error(json.message || 'Failed to delete');
|
||||||
}
|
}
|
||||||
}, { allowEmpty: window.f0ckSession?.is_admin });
|
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin Delete Attachment
|
||||||
|
const adminDelAttachBtn = target.closest('.admin-delete-attachment-btn');
|
||||||
|
if (adminDelAttachBtn) {
|
||||||
|
const filename = adminDelAttachBtn.dataset.filename;
|
||||||
|
if (filename) {
|
||||||
|
if (confirm('Are you sure you want to delete this attachment?')) {
|
||||||
|
const res = await fetch('/api/comments/attachment/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||||
|
},
|
||||||
|
body: `filename=${encodeURIComponent(filename)}`
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
const wrapper = adminDelAttachBtn.closest('.image-embed-wrap') || adminDelAttachBtn.closest('.video-embed-wrap') || adminDelAttachBtn.closest('.audio-embed-wrap');
|
||||||
|
if (wrapper) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'attachment-removed-text';
|
||||||
|
span.textContent = '[attachment removed]';
|
||||||
|
wrapper.parentNode.replaceChild(span, wrapper);
|
||||||
|
} else {
|
||||||
|
this.loadComments();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete attachment: ' + (json.message || 'Error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Admin Pin
|
// Admin Pin
|
||||||
const adminPinBtn = target.closest('.admin-pin-btn');
|
const adminPinBtn = target.closest('.admin-pin-btn');
|
||||||
if (adminPinBtn) {
|
if (adminPinBtn) {
|
||||||
@@ -2656,6 +2726,20 @@ class CommentSystem {
|
|||||||
params.append('content', text);
|
params.append('content', text);
|
||||||
if (videoTime !== null) params.append('video_time', videoTime.toFixed(3));
|
if (videoTime !== null) params.append('video_time', videoTime.toFixed(3));
|
||||||
|
|
||||||
|
// Collect file IDs from upload previews
|
||||||
|
const fileIds = [];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fileIds.length > 0) {
|
||||||
|
params.append('file_ids', fileIds.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/comments', {
|
const res = await fetch('/api/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
|||||||
@@ -252,7 +252,8 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await queue.spawn('magick', [tmpPath, '-coalesce',
|
await queue.spawn('magick', [tmpPath, '-coalesce',
|
||||||
'-resize', `${COMMENT_IMG_MAX_DIM}x${COMMENT_IMG_MAX_DIM}>`,
|
'-resize', `${COMMENT_IMG_MAX_DIM}x${COMMENT_IMG_MAX_DIM}>`,
|
||||||
'-quality', '80', webpTmpPath]);
|
'-quality', '40', '+repage',
|
||||||
|
webpTmpPath]);
|
||||||
const webpStat = await fs.stat(webpTmpPath);
|
const webpStat = await fs.stat(webpTmpPath);
|
||||||
if (webpStat.size < gifSize) {
|
if (webpStat.size < gifSize) {
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
@@ -260,6 +261,7 @@ export const handleCommentUpload = async (req, res) => {
|
|||||||
actualMime = 'image/webp';
|
actualMime = 'image/webp';
|
||||||
ext = 'webp';
|
ext = 'webp';
|
||||||
converted = true;
|
converted = true;
|
||||||
|
convertedFromGif = true;
|
||||||
console.log(`[COMMENT_UPLOAD] GIF → WebP (${(gifSize / 1024 / 1024).toFixed(1)}MB → ${(webpStat.size / 1024 / 1024).toFixed(1)}MB): ${file.filename}`);
|
console.log(`[COMMENT_UPLOAD] GIF → WebP (${(gifSize / 1024 / 1024).toFixed(1)}MB → ${(webpStat.size / 1024 / 1024).toFixed(1)}MB): ${file.filename}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[COMMENT_UPLOAD] WebP larger than GIF, keeping original: ${file.filename}`);
|
console.log(`[COMMENT_UPLOAD] WebP larger than GIF, keeping original: ${file.filename}`);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import f0cklib from "../routeinc/f0cklib.mjs";
|
|||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import audit from "../audit.mjs";
|
import audit from "../audit.mjs";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
|
|
||||||
@@ -529,6 +531,37 @@ export default (router, tpl) => {
|
|||||||
old_content: comment[0].content
|
old_content: comment[0].content
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle attachments cleanup
|
||||||
|
const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`;
|
||||||
|
for (const f of files) {
|
||||||
|
const otherRefs = await db`SELECT id FROM comment_files WHERE checksum = ${f.checksum} AND id != ${f.id}`;
|
||||||
|
const textRefs = await db`SELECT id FROM comments WHERE content LIKE ${'%' + f.dest + '%'} AND id != ${commentId}`;
|
||||||
|
|
||||||
|
const hasRefs = otherRefs.length > 0 || textRefs.length > 0;
|
||||||
|
|
||||||
|
const filePath = path.join(cfg.paths.c, f.dest);
|
||||||
|
const thumbPath = path.join(cfg.paths.t, `cf_${f.dest.split('.')[0]}.webp`);
|
||||||
|
|
||||||
|
if (!hasRefs) {
|
||||||
|
// Safe to delete from disk (last reference)
|
||||||
|
await fs.unlink(filePath).catch(() => {});
|
||||||
|
await fs.unlink(thumbPath).catch(() => {});
|
||||||
|
} else {
|
||||||
|
// There are other references. Only delete if it's a symlink AND not referenced by text!
|
||||||
|
try {
|
||||||
|
const stats = await fs.lstat(filePath);
|
||||||
|
if (stats.isSymbolicLink() && textRefs.length === 0) {
|
||||||
|
await fs.unlink(filePath).catch(() => {});
|
||||||
|
await fs.unlink(thumbPath).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[DELETE_COMMENT] Failed to check stats for ${f.dest}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete record from DB
|
||||||
|
await db`DELETE FROM comment_files WHERE id = ${f.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`;
|
await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`;
|
||||||
|
|
||||||
// Notify for live update
|
// Notify for live update
|
||||||
@@ -547,6 +580,110 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete comment attachment (admin/mod only)
|
||||||
|
router.post('/api/comments/attachment/delete', async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
|
||||||
|
const body = req.post || {};
|
||||||
|
const filename = body.filename;
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
return res.reply({ body: JSON.stringify({ success: false, message: "Missing filename" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cleanFilename = filename.split('#')[0];
|
||||||
|
const file = await db`SELECT id, comment_id, dest FROM comment_files WHERE dest = ${cleanFilename}`;
|
||||||
|
if (!file.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Attachment not found" }) });
|
||||||
|
|
||||||
|
let commentId = file[0].comment_id;
|
||||||
|
let comment;
|
||||||
|
|
||||||
|
if (!commentId) {
|
||||||
|
// Try to find the comment by content search if not linked
|
||||||
|
const commentsFound = await db`SELECT id, content, item_id FROM comments WHERE content LIKE ${'%' + cleanFilename + '%'}`;
|
||||||
|
if (commentsFound.length > 0) {
|
||||||
|
commentId = commentsFound[0].id;
|
||||||
|
comment = commentsFound;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
comment = await db`SELECT id, content, item_id FROM comments WHERE id = ${commentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commentId || !comment.length) {
|
||||||
|
// File uploaded but not linked and not found in any comment content
|
||||||
|
await db`DELETE FROM comment_files WHERE id = ${file[0].id}`;
|
||||||
|
const filePath = path.join(cfg.paths.c, file[0].dest);
|
||||||
|
await fs.unlink(filePath).catch(() => {});
|
||||||
|
return res.reply({ body: JSON.stringify({ success: true, message: "Orphaned attachment deleted" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedFilename = cleanFilename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
// Find ALL comments containing this filename and update them
|
||||||
|
const commentsToUpdate = await db`SELECT id, content FROM comments WHERE content LIKE ${'%' + cleanFilename + '%'}`;
|
||||||
|
|
||||||
|
let finalContent = comment[0].content;
|
||||||
|
for (const c of commentsToUpdate) {
|
||||||
|
const originalContent = c.content;
|
||||||
|
const domainRegex = `(?:https?:\\/\\/[^\\/\\s]+)?`;
|
||||||
|
const updatedContent = originalContent
|
||||||
|
.replace(new RegExp(`!?\\[[^\\]]*\\]\\(${domainRegex}/c/${escapedFilename}(?:#gif)?\\)`, 'g'), '[attachment removed]')
|
||||||
|
.replace(new RegExp(`${domainRegex}/c/${escapedFilename}(?:#gif)?`, 'g'), '[attachment removed]');
|
||||||
|
|
||||||
|
if (updatedContent !== originalContent) {
|
||||||
|
await db`UPDATE comments SET content = ${updatedContent}, updated_at = NOW() WHERE id = ${c.id}`;
|
||||||
|
|
||||||
|
if (c.id === commentId) {
|
||||||
|
finalContent = updatedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify for live update for each updated comment
|
||||||
|
db.notify('comments', JSON.stringify({
|
||||||
|
type: 'edit',
|
||||||
|
comment_id: c.id,
|
||||||
|
content: updatedContent
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file record
|
||||||
|
await db`DELETE FROM comment_files WHERE id = ${file[0].id}`;
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
const filePath = path.join(cfg.paths.c, file[0].dest);
|
||||||
|
await fs.unlink(filePath).catch(() => {});
|
||||||
|
|
||||||
|
// Also delete thumbnail if exists
|
||||||
|
const thumbPath = path.join(cfg.paths.t, `cf_${filename.split('.')[0]}.webp`);
|
||||||
|
await fs.unlink(thumbPath).catch(() => {});
|
||||||
|
|
||||||
|
// Log in audit log
|
||||||
|
await audit.log(req.session.id, 'delete_attachment', 'comment', commentId, {
|
||||||
|
filename: filename,
|
||||||
|
old_content: comment[0].content,
|
||||||
|
new_content: finalContent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify for live update
|
||||||
|
db.notify('comments', JSON.stringify({
|
||||||
|
type: 'edit',
|
||||||
|
item_id: comment[0].item_id,
|
||||||
|
comment_id: commentId,
|
||||||
|
content: finalContent
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ success: true })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Edit comment (admin/mod only)
|
// Edit comment (admin/mod only)
|
||||||
router.post(/\/api\/comments\/(?<id>\d+)\/edit/, async (req, res) => {
|
router.post(/\/api\/comments\/(?<id>\d+)\/edit/, async (req, res) => {
|
||||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
|||||||
Reference in New Issue
Block a user