#12 first commit
This commit is contained in:
@@ -580,6 +580,48 @@ ALTER SEQUENCE public.comments_id_seq OWNER TO f0ckm;
|
|||||||
ALTER SEQUENCE public.comments_id_seq OWNED BY public.comments.id;
|
ALTER SEQUENCE public.comments_id_seq OWNED BY public.comments.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: comment_files; Type: TABLE; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.comment_files (
|
||||||
|
id integer NOT NULL,
|
||||||
|
comment_id integer,
|
||||||
|
user_id integer NOT NULL,
|
||||||
|
dest character varying(40) NOT NULL,
|
||||||
|
mime character varying(100) NOT NULL,
|
||||||
|
size integer NOT NULL,
|
||||||
|
checksum character varying(255) NOT NULL,
|
||||||
|
phash text,
|
||||||
|
original_filename text,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.comment_files OWNER TO f0ckm;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.comment_files_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.comment_files_id_seq OWNER TO f0ckm;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: comment_files_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: f0ckm
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.comment_files_id_seq OWNED BY public.comment_files.id;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: custom_emojis; Type: TABLE; Schema: public; Owner: f0ckm
|
-- Name: custom_emojis; Type: TABLE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -2354,6 +2354,144 @@ body.layout-modern .item-sidebar-left .tag-controls {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Comment File Upload ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.comment-attach-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--nav-border-color);
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-attach-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-preview:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-preview-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 4px 6px;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-preview-item img,
|
||||||
|
.cf-preview-item video {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-preview-item i.fa-music {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-filename {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: #aaa;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-remove-btn:hover {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-preview-item.cf-uploading {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-preview-item.cf-uploading .fa-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Comment Attachments (rendered below comment content) ────────────────── */
|
||||||
|
|
||||||
|
.comment-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-attachment {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-attachment.cf-image img {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-attachment.cf-image img:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-attachment.cf-video video {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 280px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-attachment.cf-audio audio {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.comments-list {
|
.comments-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -8092,7 +8230,7 @@ span.badge.badge-current {
|
|||||||
|
|
||||||
.comment-content img:not(.emoji) {
|
.comment-content img:not(.emoji) {
|
||||||
display: block;
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smaller emojis in the narrow left sidebar */
|
/* Smaller emojis in the narrow left sidebar */
|
||||||
|
|||||||
@@ -1858,6 +1858,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)}
|
||||||
<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">
|
||||||
@@ -1901,22 +1902,46 @@ class CommentSystem {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderCommentAttachments(files) {
|
||||||
|
if (!files || files.length === 0) return '';
|
||||||
|
const items = files.map(f => {
|
||||||
|
const url = `/c/${f.dest}`;
|
||||||
|
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>`;
|
||||||
|
} else if (f.mime.startsWith('video/')) {
|
||||||
|
return `<div class="cf-attachment cf-video"><video src="${url}" controls preload="metadata"></video></div>`;
|
||||||
|
} else if (f.mime.startsWith('audio/')) {
|
||||||
|
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).join('');
|
||||||
|
return items ? `<div class="comment-attachments">${items}</div>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
renderInput(parentId = null) {
|
renderInput(parentId = null) {
|
||||||
const i18n = window.f0ckI18n || {};
|
const i18n = window.f0ckI18n || {};
|
||||||
|
const session = window.f0ckSession || {};
|
||||||
const placeholder = i18n.write_comment || 'Write a comment...';
|
const placeholder = i18n.write_comment || 'Write a comment...';
|
||||||
const postLabel = i18n.post || 'Post';
|
const postLabel = i18n.post || 'Post';
|
||||||
const cancelLabel = i18n.cancel || 'Cancel';
|
const cancelLabel = i18n.cancel || 'Cancel';
|
||||||
const maxLen = window.f0ckSession?.comment_max_length;
|
const attachLabel = i18n.attach_file || 'Attach file';
|
||||||
|
const maxLen = session.comment_max_length;
|
||||||
const maxLenAttr = (maxLen !== null && maxLen !== undefined) ? ` maxlength="${maxLen}"` : '';
|
const maxLenAttr = (maxLen !== null && maxLen !== undefined) ? ` maxlength="${maxLen}"` : '';
|
||||||
const counter = (maxLen !== null && maxLen !== undefined)
|
const counter = (maxLen !== null && maxLen !== undefined)
|
||||||
? `<span class="char-counter" data-max="${maxLen}">0 / ${maxLen}</span>`
|
? `<span class="char-counter" data-max="${maxLen}">0 / ${maxLen}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments;
|
||||||
|
const multiFile = session.fileupload_comments_multifile;
|
||||||
|
const attachBtn = fileUploadEnabled
|
||||||
|
? `<button class="comment-attach-btn" title="${attachLabel}" type="button"><i class="fa-solid fa-paperclip"></i></button><input type="file" class="comment-file-input" accept="image/*,video/*,audio/*" ${multiFile ? 'multiple' : ''} style="display:none;">`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="comment-input ${parentId ? 'reply-input' : 'main-input'}" ${parentId ? `data-parent="${parentId}"` : ''}>
|
<div class="comment-input ${parentId ? 'reply-input' : 'main-input'}" ${parentId ? `data-parent="${parentId}"` : ''}>
|
||||||
<textarea placeholder="${placeholder}"${maxLenAttr}></textarea>
|
<textarea placeholder="${placeholder}"${maxLenAttr}></textarea>
|
||||||
|
<div class="comment-file-preview"></div>
|
||||||
<div class="input-actions">
|
<div class="input-actions">
|
||||||
${counter}
|
${counter}
|
||||||
|
${attachBtn}
|
||||||
${parentId ? `<button class="cancel-reply" title="${cancelLabel}"><i class="fa-solid fa-xmark"></i></button>` : ''}
|
${parentId ? `<button class="cancel-reply" title="${cancelLabel}"><i class="fa-solid fa-xmark"></i></button>` : ''}
|
||||||
<button class="submit-comment">${postLabel}</button>
|
<button class="submit-comment">${postLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2080,6 +2105,113 @@ class CommentSystem {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Single Click Listener for Everything
|
// Single Click Listener for Everything
|
||||||
|
this.container.addEventListener('change', async (e) => {
|
||||||
|
if (!e.target.matches('.comment-file-input')) return;
|
||||||
|
const fileInput = e.target;
|
||||||
|
const wrap = fileInput.closest('.comment-input');
|
||||||
|
if (!wrap) return;
|
||||||
|
const textarea = wrap.querySelector('textarea');
|
||||||
|
if (!textarea) return;
|
||||||
|
const previewArea = wrap.querySelector('.comment-file-preview');
|
||||||
|
const session = window.f0ckSession || {};
|
||||||
|
const maxSize = session.fileupload_comments_size || (10 * 1024 * 1024);
|
||||||
|
const i18n = window.f0ckI18n || {};
|
||||||
|
const removeLabel = i18n.remove_file || 'Remove file';
|
||||||
|
|
||||||
|
for (const file of fileInput.files) {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
alert((i18n.file_too_large || 'File too large') + `: ${file.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const csrf = session.csrf_token || '';
|
||||||
|
const uploadingText = i18n.uploading_file || 'Uploading...';
|
||||||
|
|
||||||
|
// Insert placeholder at cursor position
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const before = textarea.value.substring(0, cursorPos);
|
||||||
|
const after = textarea.value.substring(textarea.selectionEnd);
|
||||||
|
const placeholder = `[${uploadingText} ${file.name}]`;
|
||||||
|
const sep = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
|
||||||
|
textarea.value = before + sep + placeholder + after;
|
||||||
|
|
||||||
|
// Show uploading preview
|
||||||
|
let previewItem = null;
|
||||||
|
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 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 url = `/c/${fileData.dest}`;
|
||||||
|
textarea.value = textarea.value.replace(placeholder, url);
|
||||||
|
|
||||||
|
// Update preview with actual thumbnail
|
||||||
|
if (previewItem) {
|
||||||
|
previewItem.classList.remove('cf-uploading');
|
||||||
|
previewItem.dataset.url = url;
|
||||||
|
previewItem.dataset.fileId = fileData.id;
|
||||||
|
previewItem.innerHTML = '';
|
||||||
|
|
||||||
|
if (fileData.mime.startsWith('image/')) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
img.loading = 'lazy';
|
||||||
|
previewItem.appendChild(img);
|
||||||
|
} else if (fileData.mime.startsWith('video/')) {
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
vid.src = url;
|
||||||
|
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 removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'cf-remove-btn';
|
||||||
|
removeBtn.title = removeLabel;
|
||||||
|
removeBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||||
|
previewItem.appendChild(removeBtn);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
textarea.value = textarea.value.replace(placeholder, '');
|
||||||
|
if (previewItem) previewItem.remove();
|
||||||
|
alert('Upload error: ' + (json.msg || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
textarea.value = textarea.value.replace(placeholder, '');
|
||||||
|
if (previewItem) previewItem.remove();
|
||||||
|
alert('Upload failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
this.container.addEventListener('click', async (e) => {
|
this.container.addEventListener('click', async (e) => {
|
||||||
_f0ckDebug('[DEBUG] Click on container:', e.target);
|
_f0ckDebug('[DEBUG] Click on container:', e.target);
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
@@ -2106,6 +2238,32 @@ class CommentSystem {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach file button
|
||||||
|
if (target.matches('.comment-attach-btn') || target.closest('.comment-attach-btn')) {
|
||||||
|
const wrap = target.closest('.comment-input');
|
||||||
|
const fileInput = wrap?.querySelector('.comment-file-input');
|
||||||
|
if (fileInput) fileInput.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove file preview + strip URL from textarea
|
||||||
|
if (target.matches('.cf-remove-btn') || target.closest('.cf-remove-btn')) {
|
||||||
|
const previewItem = target.closest('.cf-preview-item');
|
||||||
|
if (previewItem) {
|
||||||
|
const url = previewItem.dataset.url;
|
||||||
|
if (url) {
|
||||||
|
const wrap = previewItem.closest('.comment-input');
|
||||||
|
const textarea = wrap?.querySelector('textarea');
|
||||||
|
if (textarea) {
|
||||||
|
// Remove the URL and any surrounding newline
|
||||||
|
textarea.value = textarea.value.replace(new RegExp('\\n?' + url.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + '\\n?'), '\n').replace(/^\n|\n$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewItem.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Submit Comment
|
// Submit Comment
|
||||||
if (target.matches('.submit-comment')) {
|
if (target.matches('.submit-comment')) {
|
||||||
this.handleSubmit(e);
|
this.handleSubmit(e);
|
||||||
@@ -2467,6 +2625,8 @@ class CommentSystem {
|
|||||||
counter.textContent = `0 / ${counter.dataset.max}`;
|
counter.textContent = `0 / ${counter.dataset.max}`;
|
||||||
counter.classList.remove('near-limit', 'at-limit');
|
counter.classList.remove('near-limit', 'at-limit');
|
||||||
}
|
}
|
||||||
|
const fpArea = wrap.querySelector('.comment-file-preview');
|
||||||
|
if (fpArea) fpArea.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the right sidebar that a new comment was posted (silent refresh)
|
// Notify the right sidebar that a new comment was posted (silent refresh)
|
||||||
|
|||||||
@@ -7831,6 +7831,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
} else {
|
} else {
|
||||||
openImageModal(elfe.href);
|
openImageModal(elfe.href);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment embedded images → open in image modal
|
||||||
|
const commentImg = e.target.closest('.comment-content img, .comment-attachments img');
|
||||||
|
if (commentImg) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openImageModal(commentImg.src);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,7 +128,8 @@
|
|||||||
const hostsRegexPart = allowedHosts.join('|');
|
const hostsRegexPart = allowedHosts.join('|');
|
||||||
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
||||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||||
const imageRegex = new RegExp(`(?<![\\(\\[])((?:https?:\\/\\/)?(?:${hostsRegexPart})(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?<!\\S)(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||||
|
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
||||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
|
|||||||
497
src/comment_upload_handler.mjs
Normal file
497
src/comment_upload_handler.mjs
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import { promises as fs } from "fs";
|
||||||
|
import db from "./inc/sql.mjs";
|
||||||
|
import lib from "./inc/lib.mjs";
|
||||||
|
import cfg from "./inc/config.mjs";
|
||||||
|
import queue from "./inc/queue.mjs";
|
||||||
|
import path from "path";
|
||||||
|
import { collectBody } from "./inc/multipart.mjs";
|
||||||
|
|
||||||
|
// Helper for JSON response
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
// One-time migration: ensure comment_files table exists
|
||||||
|
db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
|
||||||
|
dest VARCHAR(40) NOT NULL,
|
||||||
|
mime VARCHAR(100) NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
checksum VARCHAR(255) NOT NULL,
|
||||||
|
phash TEXT,
|
||||||
|
original_filename TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`.catch(() => {});
|
||||||
|
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => {});
|
||||||
|
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse multipart form data supporting multiple files with the same field name.
|
||||||
|
* Returns { files: Array<{filename, contentType, data}>, fields: Object }
|
||||||
|
*/
|
||||||
|
const parseMultipartFiles = (buffer, boundary) => {
|
||||||
|
const files = [];
|
||||||
|
const fields = {};
|
||||||
|
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
||||||
|
const segments = [];
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let idx;
|
||||||
|
|
||||||
|
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
|
||||||
|
if (start !== 0) {
|
||||||
|
segments.push(buffer.slice(start, idx - 2));
|
||||||
|
}
|
||||||
|
start = idx + boundaryBuffer.length + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const headerEnd = segment.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd === -1) continue;
|
||||||
|
|
||||||
|
const headers = segment.slice(0, headerEnd).toString();
|
||||||
|
const body = segment.slice(headerEnd + 4);
|
||||||
|
|
||||||
|
const nameMatch = headers.match(/name="([^"]+)"/);
|
||||||
|
|
||||||
|
let extractedFilename = null;
|
||||||
|
const filenameStarMatch = headers.match(/filename\*\s*=\s*[Uu][Tt][Ff]-8''([^\r\n;]+)/i);
|
||||||
|
if (filenameStarMatch) {
|
||||||
|
try { extractedFilename = decodeURIComponent(filenameStarMatch[1].trim()); } catch (e) { extractedFilename = filenameStarMatch[1].trim(); }
|
||||||
|
} else {
|
||||||
|
const filenameQuotedMatch = headers.match(/filename="((?:[^"\\]|\\.)*)"/);
|
||||||
|
if (filenameQuotedMatch) {
|
||||||
|
extractedFilename = filenameQuotedMatch[1].replace(/\\(.)/g, '$1');
|
||||||
|
} else {
|
||||||
|
const filenameUnquotedMatch = headers.match(/filename=([^\r\n;]+)/);
|
||||||
|
if (filenameUnquotedMatch) extractedFilename = filenameUnquotedMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||||
|
|
||||||
|
if (nameMatch) {
|
||||||
|
const name = nameMatch[1];
|
||||||
|
if (extractedFilename !== null) {
|
||||||
|
files.push({
|
||||||
|
fieldName: name,
|
||||||
|
filename: extractedFilename,
|
||||||
|
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
|
||||||
|
data: body
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fields[name] = body.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files, fields };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the allowed MIME list for comment uploads (image/*, video/*, audio/*).
|
||||||
|
* Filters from cfg.mimes, excluding PDF, SWF, etc.
|
||||||
|
*/
|
||||||
|
const getAllowedCommentMimes = () => {
|
||||||
|
return Object.keys(cfg.mimes).filter(mime =>
|
||||||
|
mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleCommentUpload = async (req, res) => {
|
||||||
|
// Manual session lookup (same pattern as upload_handler.mjs)
|
||||||
|
if (req.cookies?.session) {
|
||||||
|
try {
|
||||||
|
const user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
if (user.length > 0) {
|
||||||
|
req.session = user[0];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Session lookup failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.session) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if comment file upload is enabled
|
||||||
|
if (!cfg.websrv.allow_fileupload_comments) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Comment file uploads are disabled' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||||
|
|
||||||
|
if (!contentType.includes('multipart/form-data') || !boundaryMatch) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||||
|
|
||||||
|
// Determine max file size
|
||||||
|
let maxFileSize = cfg.websrv.fileupload_comments_size || (10 * 1024 * 1024);
|
||||||
|
if (req.session.admin) {
|
||||||
|
maxFileSize = Math.floor(maxFileSize * (cfg.main.adminmultiplier || 3.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await collectBody(req, maxFileSize * 10); // Allow overhead for multipart framing
|
||||||
|
} catch (bodyErr) {
|
||||||
|
if (bodyErr.code === 'BODY_TOO_LARGE') {
|
||||||
|
return sendJson(res, { success: false, msg: 'Request body too large' }, 413);
|
||||||
|
}
|
||||||
|
throw bodyErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { files, fields } = parseMultipartFiles(body, boundary);
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
return sendJson(res, { success: false, msg: 'No files provided' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-file check
|
||||||
|
const multiFileAllowed = cfg.websrv.fileupload_comments_multifile;
|
||||||
|
if (!multiFileAllowed && files.length > 1) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedMimes = getAllowedCommentMimes();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Ensure directories exist
|
||||||
|
await fs.mkdir(cfg.paths.c, { recursive: true });
|
||||||
|
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||||
|
await fs.mkdir(cfg.paths.t, { recursive: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Size check per file
|
||||||
|
if (file.data.length > maxFileSize) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `File "${file.filename}" exceeds the maximum size limit`
|
||||||
|
}, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME check (browser-reported)
|
||||||
|
if (!allowedMimes.includes(file.contentType)) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type: ${file.contentType}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate UUID and save temp
|
||||||
|
const uuid = await queue.genuuid();
|
||||||
|
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||||
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
|
||||||
|
// Verify actual MIME with `file` command
|
||||||
|
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
||||||
|
if (!allowedMimes.includes(actualMime)) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type detected: ${actualMime}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reclassify audio-only MP4 containers (same as upload_handler)
|
||||||
|
if (actualMime === 'video/mp4' || actualMime === 'video/quicktime') {
|
||||||
|
const origExt = file.filename.split('.').pop().toLowerCase();
|
||||||
|
if (['m4a', 'aac'].includes(origExt)) {
|
||||||
|
actualMime = 'audio/mp4';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const probeResult = await queue.spawn('ffprobe', [
|
||||||
|
'-v', 'error', '-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=codec_type',
|
||||||
|
'-of', 'csv=p=0', tmpPath
|
||||||
|
]);
|
||||||
|
if (!probeResult.stdout.trim()) {
|
||||||
|
actualMime = 'audio/mp4';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ffprobe not available or failed, keep original MIME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = cfg.mimes[actualMime] || 'bin';
|
||||||
|
const filename = `${uuid}.${ext}`;
|
||||||
|
|
||||||
|
// SHA-256 checksum
|
||||||
|
const checksum = (await queue.spawn('sha256sum', [tmpPath])).stdout.trim().split(" ")[0];
|
||||||
|
|
||||||
|
// Repost detection: check both comment_files AND items tables
|
||||||
|
let linkedToExisting = false;
|
||||||
|
|
||||||
|
// Check comment_files first
|
||||||
|
const commentRepost = await db`
|
||||||
|
SELECT id, dest FROM comment_files WHERE checksum = ${checksum} LIMIT 1
|
||||||
|
`;
|
||||||
|
if (commentRepost.length > 0) {
|
||||||
|
// Symlink to existing comment file
|
||||||
|
const existingDest = commentRepost[0].dest;
|
||||||
|
const existingAbsPath = path.join(cfg.paths.c, existingDest);
|
||||||
|
try {
|
||||||
|
const realTarget = await fs.realpath(existingAbsPath);
|
||||||
|
const destPath = path.join(cfg.paths.c, filename);
|
||||||
|
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
||||||
|
await fs.symlink(relTarget, destPath);
|
||||||
|
linkedToExisting = true;
|
||||||
|
console.log(`[COMMENT_UPLOAD] Symlinked to existing comment file: ${filename} → ${relTarget}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[COMMENT_UPLOAD] Symlink failed:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linkedToExisting) {
|
||||||
|
// Check items table
|
||||||
|
const itemRepost = await db`
|
||||||
|
SELECT id, dest FROM items WHERE checksum = ${checksum} LIMIT 1
|
||||||
|
`;
|
||||||
|
if (itemRepost.length > 0) {
|
||||||
|
const existingDest = itemRepost[0].dest;
|
||||||
|
const existingAbsPath = path.join(cfg.paths.b, existingDest);
|
||||||
|
try {
|
||||||
|
const realTarget = await fs.realpath(existingAbsPath);
|
||||||
|
const destPath = path.join(cfg.paths.c, filename);
|
||||||
|
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
||||||
|
await fs.symlink(relTarget, destPath);
|
||||||
|
linkedToExisting = true;
|
||||||
|
console.log(`[COMMENT_UPLOAD] Symlinked to existing item: ${filename} → ${relTarget}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[COMMENT_UPLOAD] Item symlink failed:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHash check (only for image/video)
|
||||||
|
let phash = null;
|
||||||
|
if (actualMime.startsWith('image/') || actualMime.startsWith('video/')) {
|
||||||
|
try {
|
||||||
|
phash = await queue.generatePHash(tmpPath);
|
||||||
|
if (phash && !linkedToExisting) {
|
||||||
|
// Check comment_files for visual duplicate
|
||||||
|
const cfItems = await db`
|
||||||
|
SELECT id, phash, dest FROM comment_files
|
||||||
|
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
|
||||||
|
`;
|
||||||
|
for (const cf of cfItems) {
|
||||||
|
if (isPhashMatch(phash, cf.phash)) {
|
||||||
|
const existingAbsPath = path.join(cfg.paths.c, cf.dest);
|
||||||
|
try {
|
||||||
|
const realTarget = await fs.realpath(existingAbsPath);
|
||||||
|
const destPath = path.join(cfg.paths.c, filename);
|
||||||
|
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
||||||
|
await fs.symlink(relTarget, destPath);
|
||||||
|
linkedToExisting = true;
|
||||||
|
console.log(`[COMMENT_UPLOAD] PHash match in comment_files: ${filename} → ${relTarget}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check items table for visual duplicate
|
||||||
|
if (!linkedToExisting) {
|
||||||
|
const phashMatch = await queue.checkrepostphash(phash);
|
||||||
|
if (phashMatch) {
|
||||||
|
const itemRow = await db`SELECT dest FROM items WHERE id = ${phashMatch} LIMIT 1`;
|
||||||
|
if (itemRow.length > 0) {
|
||||||
|
const existingAbsPath = path.join(cfg.paths.b, itemRow[0].dest);
|
||||||
|
try {
|
||||||
|
const realTarget = await fs.realpath(existingAbsPath);
|
||||||
|
const destPath = path.join(cfg.paths.c, filename);
|
||||||
|
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
||||||
|
await fs.symlink(relTarget, destPath);
|
||||||
|
linkedToExisting = true;
|
||||||
|
console.log(`[COMMENT_UPLOAD] PHash match in items: ${filename} → ${relTarget}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[COMMENT_UPLOAD] PHash item symlink failed:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[COMMENT_UPLOAD] PHash error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no duplicate found, copy file to /c/
|
||||||
|
if (!linkedToExisting) {
|
||||||
|
const destPath = path.join(cfg.paths.c, filename);
|
||||||
|
await fs.copyFile(tmpPath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tmp
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
|
||||||
|
// Generate thumbnail (same size as regular uploads = 512px)
|
||||||
|
const dynThumbSize = 512;
|
||||||
|
try {
|
||||||
|
// genThumbnail expects the file in bDir (pending/b or b).
|
||||||
|
// For comment files we store in /c/, so we call thumbnail generation manually.
|
||||||
|
await generateCommentThumbnail(filename, actualMime, uuid, dynThumbSize);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[COMMENT_UPLOAD] Thumbnail generation failed for ${filename}:`, err.message);
|
||||||
|
// Fallback to placeholder
|
||||||
|
const tPath = path.join(cfg.paths.t, `cf_${uuid}.webp`);
|
||||||
|
await queue.spawn('magick', ['-size', `${dynThumbSize}x${dynThumbSize}`, 'xc:#1a1a1a', tPath]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into comment_files (comment_id is null; will be linked when comment is posted)
|
||||||
|
const inserted = await db`
|
||||||
|
INSERT INTO comment_files ${db({
|
||||||
|
user_id: req.session.id,
|
||||||
|
dest: filename,
|
||||||
|
mime: actualMime,
|
||||||
|
size: file.data.length,
|
||||||
|
checksum: checksum,
|
||||||
|
phash: phash,
|
||||||
|
original_filename: file.filename || null
|
||||||
|
}, 'user_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'original_filename')}
|
||||||
|
RETURNING id, dest, mime
|
||||||
|
`;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: inserted[0].id,
|
||||||
|
dest: inserted[0].dest,
|
||||||
|
mime: inserted[0].mime,
|
||||||
|
thumbnail: `/t/cf_${uuid}.webp`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, { success: true, files: results });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[COMMENT_UPLOAD] Error:', err);
|
||||||
|
if (err.code === 'BODY_TOO_LARGE') {
|
||||||
|
return sendJson(res, { success: false, msg: 'File too large' }, 413);
|
||||||
|
}
|
||||||
|
return sendJson(res, { success: false, msg: 'Upload failed' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate thumbnail for a comment file.
|
||||||
|
* Outputs to /t/cf_<uuid>.webp
|
||||||
|
*/
|
||||||
|
async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
|
||||||
|
const sourcePath = path.join(cfg.paths.c, filename);
|
||||||
|
const thumbDest = path.join(cfg.paths.t, `cf_${uuid}.webp`);
|
||||||
|
const tmpFile = path.join(cfg.paths.tmp, `cf_${uuid}_thumb.png`);
|
||||||
|
const thumbSpec = `${size}x${size}`;
|
||||||
|
|
||||||
|
// Resolve real path if symlink
|
||||||
|
let realSource = sourcePath;
|
||||||
|
try {
|
||||||
|
const lstat = await fs.lstat(sourcePath);
|
||||||
|
if (lstat.isSymbolicLink()) {
|
||||||
|
realSource = await fs.realpath(sourcePath);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (mime.startsWith('video/') || mime === 'image/gif') {
|
||||||
|
const ffThumbSize = Math.max(size, 512);
|
||||||
|
const seeks = ['20%', '40%', '60%', '80%'];
|
||||||
|
for (const seek of seeks) {
|
||||||
|
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||||
|
try {
|
||||||
|
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
||||||
|
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||||
|
} catch (e) { break; }
|
||||||
|
}
|
||||||
|
} else if (mime.startsWith('image/') && mime !== 'image/gif') {
|
||||||
|
await queue.spawn('magick', [realSource + '[0]', tmpFile]);
|
||||||
|
} else if (mime.startsWith('audio/')) {
|
||||||
|
// Try extracting cover art
|
||||||
|
let coverExtracted = false;
|
||||||
|
try {
|
||||||
|
await queue.spawn('ffmpeg', ['-i', realSource, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpFile]);
|
||||||
|
const stat = await fs.stat(tmpFile).catch(() => null);
|
||||||
|
if (stat && stat.size > 0) {
|
||||||
|
coverExtracted = true;
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
if (!coverExtracted) {
|
||||||
|
// Generate a placeholder for audio
|
||||||
|
await queue.spawn('magick', ['-size', thumbSpec, 'xc:#1a1a1a', '-gravity', 'center',
|
||||||
|
'-fill', '#ffffff', '-pointsize', '48', '-annotate', '0', '♪',
|
||||||
|
tmpFile]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to webp thumbnail
|
||||||
|
try {
|
||||||
|
await queue.spawn('magick', [tmpFile, '-resize', `${thumbSpec}^`, '-gravity', 'center',
|
||||||
|
'-crop', `${thumbSpec}+0+0`, '+repage', '-quality', '85', thumbDest]);
|
||||||
|
} catch (e) {
|
||||||
|
// If conversion fails, create placeholder
|
||||||
|
await queue.spawn('magick', ['-size', thumbSpec, 'xc:#1a1a1a', thumbDest]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup tmp
|
||||||
|
await fs.unlink(tmpFile).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHash matching helper (same logic as queue.checkrepostphash)
|
||||||
|
*/
|
||||||
|
function isPhashMatch(newHash, dbHash) {
|
||||||
|
if (!newHash || !dbHash) return false;
|
||||||
|
const newHashes = newHash.split('_');
|
||||||
|
const dbHashes = dbHash.split('_');
|
||||||
|
const THRESHOLD = 15;
|
||||||
|
|
||||||
|
const getHammingDistance = (h1, h2) => {
|
||||||
|
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
||||||
|
let distance = 0;
|
||||||
|
for (let i = 0; i < h1.length; i += 2) {
|
||||||
|
const v1 = parseInt(h1.substr(i, 2), 16);
|
||||||
|
const v2 = parseInt(h2.substr(i, 2), 16);
|
||||||
|
let xor = v1 ^ v2;
|
||||||
|
while (xor) {
|
||||||
|
distance += xor & 1;
|
||||||
|
xor >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
|
||||||
|
let matches = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < framesToCompare; i++) {
|
||||||
|
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
|
||||||
|
if (dist <= THRESHOLD) matches++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (framesToCompare >= 3 && matches >= 2) return true;
|
||||||
|
if (framesToCompare === 1 && matches === 1) return true;
|
||||||
|
if (framesToCompare === 2 && matches >= 2) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ const resolvePath = (defaultRel) => {
|
|||||||
config.paths = {
|
config.paths = {
|
||||||
a: resolvePath('public/a'),
|
a: resolvePath('public/a'),
|
||||||
b: resolvePath('public/b'),
|
b: resolvePath('public/b'),
|
||||||
|
c: resolvePath('public/c'),
|
||||||
t: resolvePath('public/t'),
|
t: resolvePath('public/t'),
|
||||||
ca: resolvePath('public/ca'),
|
ca: resolvePath('public/ca'),
|
||||||
s: path.join(base, 'public/s'),
|
s: path.join(base, 'public/s'),
|
||||||
|
|||||||
@@ -309,7 +309,11 @@
|
|||||||
"comments": {
|
"comments": {
|
||||||
"write_comment": "Kommentar schreiben...",
|
"write_comment": "Kommentar schreiben...",
|
||||||
"post": "Abschnalzen",
|
"post": "Abschnalzen",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen",
|
||||||
|
"attach_file": "Datei anhängen",
|
||||||
|
"uploading_file": "Wird hochgeladen...",
|
||||||
|
"remove_file": "Datei entfernen",
|
||||||
|
"file_too_large": "Datei zu groß"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Datei auswählen",
|
"select_file": "Datei auswählen",
|
||||||
|
|||||||
@@ -309,7 +309,11 @@
|
|||||||
"comments": {
|
"comments": {
|
||||||
"write_comment": "Write a comment...",
|
"write_comment": "Write a comment...",
|
||||||
"post": "Post",
|
"post": "Post",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"attach_file": "Attach file",
|
||||||
|
"uploading_file": "Uploading...",
|
||||||
|
"remove_file": "Remove file",
|
||||||
|
"file_too_large": "File too large"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Select a file",
|
"select_file": "Select a file",
|
||||||
|
|||||||
@@ -309,7 +309,11 @@
|
|||||||
"comments": {
|
"comments": {
|
||||||
"write_comment": "Schrijf een opmerking...",
|
"write_comment": "Schrijf een opmerking...",
|
||||||
"post": "Plaatsen",
|
"post": "Plaatsen",
|
||||||
"cancel": "Annuleren"
|
"cancel": "Annuleren",
|
||||||
|
"attach_file": "Bestand bijvoegen",
|
||||||
|
"uploading_file": "Uploaden...",
|
||||||
|
"remove_file": "Bestand verwijderen",
|
||||||
|
"file_too_large": "Bestand te groot"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Selecteer een bestand",
|
"select_file": "Selecteer een bestand",
|
||||||
|
|||||||
@@ -308,7 +308,11 @@
|
|||||||
"comments": {
|
"comments": {
|
||||||
"write_comment": "Schreiben Sie doch einen Kommentar...",
|
"write_comment": "Schreiben Sie doch einen Kommentar...",
|
||||||
"post": "Pfostieren",
|
"post": "Pfostieren",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen",
|
||||||
|
"attach_file": "Datei anflanschen",
|
||||||
|
"uploading_file": "Wird aufladiert...",
|
||||||
|
"remove_file": "Datei entfernen",
|
||||||
|
"file_too_large": "Datei zu voluminös"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
"select_file": "Datei auswählen",
|
"select_file": "Datei auswählen",
|
||||||
|
|||||||
@@ -861,6 +861,31 @@ export default {
|
|||||||
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
|
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
|
||||||
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
|
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Fetch comment file attachments
|
||||||
|
if (comments.length > 0) {
|
||||||
|
const commentIds = comments.map(c => c.id);
|
||||||
|
try {
|
||||||
|
const files = await db`
|
||||||
|
SELECT id, comment_id, dest, mime, size, original_filename
|
||||||
|
FROM comment_files
|
||||||
|
WHERE comment_id = ANY(${commentIds}::int[])
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
const filesMap = new Map();
|
||||||
|
for (const f of files) {
|
||||||
|
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
|
||||||
|
filesMap.get(f.comment_id).push(f);
|
||||||
|
}
|
||||||
|
for (const c of comments) {
|
||||||
|
c.files = filesMap.get(c.id) || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Table might not exist yet, gracefully degrade
|
||||||
|
for (const c of comments) c.files = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`);
|
console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`);
|
||||||
|
|
||||||
// Process mentions (now includes embeds)
|
// Process mentions (now includes embeds)
|
||||||
@@ -886,6 +911,20 @@ export default {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
if (!comment.length) return null;
|
if (!comment.length) return null;
|
||||||
|
|
||||||
|
// Fetch comment file attachments
|
||||||
|
try {
|
||||||
|
const files = await db`
|
||||||
|
SELECT id, comment_id, dest, mime, size, original_filename
|
||||||
|
FROM comment_files
|
||||||
|
WHERE comment_id = ${id}
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
comment[0].files = files;
|
||||||
|
} catch (e) {
|
||||||
|
comment[0].files = [];
|
||||||
|
}
|
||||||
|
|
||||||
return process ? (await processMentions(comment))[0] : comment[0];
|
return process ? (await processMentions(comment))[0] : comment[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[F0CKLIB] Error fetching comment:', e);
|
console.error('[F0CKLIB] Error fetching comment:', e);
|
||||||
|
|||||||
@@ -290,6 +290,26 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const commentId = parseInt(newComment[0].id, 10);
|
const commentId = parseInt(newComment[0].id, 10);
|
||||||
|
|
||||||
|
// Link uploaded files to this comment (if any)
|
||||||
|
const fileIdsRaw = body.file_ids || '';
|
||||||
|
if (fileIdsRaw) {
|
||||||
|
const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
|
||||||
|
if (fileIds.length > 0) {
|
||||||
|
try {
|
||||||
|
// Only link files that belong to this user and aren't already linked
|
||||||
|
await db`
|
||||||
|
UPDATE comment_files
|
||||||
|
SET comment_id = ${commentId}
|
||||||
|
WHERE id = ANY(${fileIds}::int[])
|
||||||
|
AND user_id = ${req.session.id}
|
||||||
|
AND comment_id IS NULL
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[COMMENTS] Failed to link files to comment:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify Subscribers (excluding the author)
|
// Notify Subscribers (excluding the author)
|
||||||
// 1. Get subscribers (active only)
|
// 1. Get subscribers (active only)
|
||||||
const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id} AND is_subscribed = true`;
|
const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id} AND is_subscribed = true`;
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export default (router, tpl) => {
|
|||||||
route: /^\/b\//
|
route: /^\/b\//
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.static({
|
||||||
|
dir: cfg.paths.c,
|
||||||
|
route: /^\/c\//
|
||||||
|
});
|
||||||
|
|
||||||
router.static({
|
router.static({
|
||||||
dir: cfg.paths.emojis,
|
dir: cfg.paths.emojis,
|
||||||
route: /^\/s\/emojis\//
|
route: /^\/s\/emojis\//
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
|
|||||||
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
||||||
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||||
|
import { handleCommentUpload } from "./comment_upload_handler.mjs";
|
||||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
|
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
|
||||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||||
import { createI18n } from "./inc/i18n.mjs";
|
import { createI18n } from "./inc/i18n.mjs";
|
||||||
@@ -345,7 +346,7 @@ process.on('uncaughtException', err => {
|
|||||||
|
|
||||||
// Ensure storage directories exist
|
// Ensure storage directories exist
|
||||||
const initDirs = [
|
const initDirs = [
|
||||||
cfg.paths.a, cfg.paths.b, cfg.paths.t, cfg.paths.ca, cfg.paths.emojis, cfg.paths.memes, cfg.paths.tmp, cfg.paths.logs,
|
cfg.paths.a, cfg.paths.b, cfg.paths.c, cfg.paths.t, cfg.paths.ca, cfg.paths.emojis, cfg.paths.memes, cfg.paths.tmp, cfg.paths.logs,
|
||||||
path.join(cfg.paths.pending, 'b'), path.join(cfg.paths.pending, 't'), path.join(cfg.paths.pending, 'ca'),
|
path.join(cfg.paths.pending, 'b'), path.join(cfg.paths.pending, 't'), path.join(cfg.paths.pending, 'ca'),
|
||||||
path.join(cfg.paths.deleted, 'b'), path.join(cfg.paths.deleted, 't'), path.join(cfg.paths.deleted, 'ca')
|
path.join(cfg.paths.deleted, 'b'), path.join(cfg.paths.deleted, 't'), path.join(cfg.paths.deleted, 'ca')
|
||||||
];
|
];
|
||||||
@@ -483,7 +484,7 @@ process.on('uncaughtException', err => {
|
|||||||
return;
|
return;
|
||||||
if (req.url.pathname === '/manifest.json' || req.url.pathname === '/sw.js')
|
if (req.url.pathname === '/manifest.json' || req.url.pathname === '/sw.js')
|
||||||
return;
|
return;
|
||||||
if (req.url.pathname.match(/^\/(b|t|ca|a|memes)\//) || req.url.pathname.startsWith('/s/emojis/')) {
|
if (req.url.pathname.match(/^\/(b|c|t|ca|a|memes)\//) || req.url.pathname.startsWith('/s/emojis/')) {
|
||||||
if (cfg.websrv.private_society && !req.cookies?.session) {
|
if (cfg.websrv.private_society && !req.cookies?.session) {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
|
||||||
req.url.pathname = '/private_society_media_bypass';
|
req.url.pathname = '/private_society_media_bypass';
|
||||||
@@ -710,7 +711,7 @@ process.on('uncaughtException', err => {
|
|||||||
// because the session middleware will have completed by the time router callbacks execute.
|
// because the session middleware will have completed by the time router callbacks execute.
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
||||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta'].includes(req.url.pathname)) return;
|
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload'].includes(req.url.pathname)) return;
|
||||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
// Hall manager routes are handled by bypass middleware with their own session auth
|
||||||
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
||||||
// User hall image upload is handled by bypass middleware below
|
// User hall image upload is handled by bypass middleware below
|
||||||
@@ -825,6 +826,14 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for comment file uploads (multipart — needs raw body)
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
if (req.method === 'POST' && req.url.pathname === '/api/v2/comments/upload') {
|
||||||
|
await handleCommentUpload(req, res);
|
||||||
|
req.url.pathname = '/handled_comment_upload_bypass';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tpl.views = "views";
|
tpl.views = "views";
|
||||||
tpl.debug = true;
|
tpl.debug = true;
|
||||||
tpl.cache = false;
|
tpl.cache = false;
|
||||||
@@ -1082,6 +1091,10 @@ process.on('uncaughtException', err => {
|
|||||||
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
||||||
paths_images: cfg.websrv.paths?.images || '/b',
|
paths_images: cfg.websrv.paths?.images || '/b',
|
||||||
default_comment_display_mode: cfg.websrv.default_comment_display_mode || 0,
|
default_comment_display_mode: cfg.websrv.default_comment_display_mode || 0,
|
||||||
|
allow_fileupload_comments: cfg.websrv.allow_fileupload_comments || false,
|
||||||
|
fileupload_comments_multifile: cfg.websrv.fileupload_comments_multifile || false,
|
||||||
|
fileupload_comments_size: cfg.websrv.fileupload_comments_size || (10 * 1024 * 1024),
|
||||||
|
fileupload_comments_mode: cfg.websrv.fileupload_comments_mode || 'attachment',
|
||||||
|
|
||||||
get fonts() {
|
get fonts() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -406,7 +406,11 @@
|
|||||||
do_not_disturb: @if(session && session.do_not_disturb) true @else false @endif,
|
do_not_disturb: @if(session && session.do_not_disturb) true @else false @endif,
|
||||||
comment_display_mode: {{ comment_display_mode }},
|
comment_display_mode: {{ comment_display_mode }},
|
||||||
comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }},
|
comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }},
|
||||||
development: @if(development) true @else false @endif
|
development: @if(development) true @else false @endif,
|
||||||
|
allow_fileupload_comments: @if(allow_fileupload_comments) true @else false @endif,
|
||||||
|
fileupload_comments_multifile: @if(fileupload_comments_multifile) true @else false @endif,
|
||||||
|
fileupload_comments_size: {{ fileupload_comments_size }},
|
||||||
|
fileupload_comments_mode: "{{ fileupload_comments_mode }}"
|
||||||
};
|
};
|
||||||
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
|
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
|
||||||
window.f0ckI18n = {
|
window.f0ckI18n = {
|
||||||
@@ -550,7 +554,12 @@
|
|||||||
chat_expand: "{{ t('chat.expand') }}",
|
chat_expand: "{{ t('chat.expand') }}",
|
||||||
chat_slow_down: "{{ t('chat.slow_down') }}",
|
chat_slow_down: "{{ t('chat.slow_down') }}",
|
||||||
chat_error_send: "{{ t('chat.error_send') }}",
|
chat_error_send: "{{ t('chat.error_send') }}",
|
||||||
chat_network_error: "{{ t('chat.network_error') }}"
|
chat_network_error: "{{ t('chat.network_error') }}",
|
||||||
|
// comment file upload
|
||||||
|
attach_file: "{{ t('comments.attach_file') }}",
|
||||||
|
uploading_file: "{{ t('comments.uploading_file') }}",
|
||||||
|
remove_file: "{{ t('comments.remove_file') }}",
|
||||||
|
file_too_large: "{{ t('comments.file_too_large') }}"
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="/s/js/f0ckm.js?v={{ ts }}"></script>
|
<script src="/s/js/f0ckm.js?v={{ ts }}"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user