Improve image uploads and previews
This commit is contained in:
@@ -126,6 +126,85 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
.message .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
.message p { margin: 0; white-space: pre-wrap; }
|
||||
.message a { color: var(--brand); display: inline-block; margin-top: 6px; }
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.photo-preview {
|
||||
width: 116px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
.photo-preview img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #edf3ef;
|
||||
}
|
||||
.photo-preview span {
|
||||
padding: 0 7px 7px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.photo-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.photo-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: rgb(0 0 0 / 72%);
|
||||
}
|
||||
.photo-modal figure {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
max-width: min(1100px, 94vw);
|
||||
max-height: 90vh;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.photo-modal img {
|
||||
max-width: 100%;
|
||||
max-height: 82vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
.photo-modal figcaption {
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.photo-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(0 0 0 / 54%);
|
||||
}
|
||||
.typing { padding: 0 20px 10px; color: var(--brand); font-size: 13px; }
|
||||
.reply {
|
||||
display: grid;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MaalFlows Admin</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+52
-1
@@ -87,6 +87,12 @@ $("#statusSelect").addEventListener("change", async () => {
|
||||
await loadConversations();
|
||||
});
|
||||
|
||||
$("#messages").addEventListener("click", (event) => {
|
||||
const preview = event.target.closest("[data-photo-url]");
|
||||
if (!preview) return;
|
||||
openPhotoPreview(preview.dataset.photoUrl, preview.dataset.photoName);
|
||||
});
|
||||
|
||||
async function showApp() {
|
||||
$("#loginView").classList.add("hidden");
|
||||
$("#appView").classList.remove("hidden");
|
||||
@@ -188,12 +194,57 @@ function renderActive() {
|
||||
<article class="message ${message.senderType}">
|
||||
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
|
||||
<p>${escapeHtml(message.body)}</p>
|
||||
${message.attachments.map((a) => `<a href="${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).join("")}
|
||||
${renderAttachments(message.attachments)}
|
||||
</article>
|
||||
`).join("");
|
||||
$("#messages").scrollTop = $("#messages").scrollHeight;
|
||||
}
|
||||
|
||||
function renderAttachments(attachments) {
|
||||
if (!attachments.length) return "";
|
||||
return `<div class="attachments">${attachments.map((attachment) => {
|
||||
if (!attachment.type?.startsWith("image/")) {
|
||||
return `<a href="${escapeHtml(attachment.url)}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>`;
|
||||
}
|
||||
return `
|
||||
<button class="photo-preview" type="button" data-photo-url="${escapeHtml(attachment.url)}" data-photo-name="${escapeHtml(attachment.name)}">
|
||||
<img src="${escapeHtml(attachment.url)}" alt="${escapeHtml(attachment.name)}">
|
||||
<span>${escapeHtml(attachment.name)}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function openPhotoPreview(url, name) {
|
||||
let modal = $("#photoModal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "photoModal";
|
||||
modal.className = "photo-modal hidden";
|
||||
modal.innerHTML = `
|
||||
<button class="photo-backdrop" type="button" aria-label="Zavrit nahled"></button>
|
||||
<figure>
|
||||
<button class="photo-close" type="button" aria-label="Zavrit nahled">×</button>
|
||||
<img alt="">
|
||||
<figcaption></figcaption>
|
||||
</figure>
|
||||
`;
|
||||
document.body.append(modal);
|
||||
modal.addEventListener("click", (event) => {
|
||||
if (event.target.closest(".photo-close") || event.target.classList.contains("photo-backdrop")) {
|
||||
modal.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") modal.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
modal.querySelector("img").src = url;
|
||||
modal.querySelector("img").alt = name || "Fotka";
|
||||
modal.querySelector("figcaption").textContent = name || "";
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function saveSite(patch) {
|
||||
const data = await api("/api/admin/site", { method: "PATCH", body: patch });
|
||||
state.site = data.site;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0f8f6f"/>
|
||||
<path fill="#fff" d="M15 38c0-10.5 7.5-18 17.2-18 8.8 0 15.8 6.4 15.8 14.6 0 8.8-6.5 15.4-15.4 15.4H18.8c-2.1 0-3.3-2.4-2-4l3-4.1A16.6 16.6 0 0 1 15 38Zm17.2-10.4c-5.5 0-9.7 4.3-9.7 10 0 1.9.6 3.7 1.6 5.2l.9 1.3-1.3 1.8h8.9c4.8 0 8.4-3.5 8.4-8.2 0-5.6-3.8-10.1-8.8-10.1Z"/>
|
||||
<circle cx="29" cy="37" r="2.5" fill="#fff"/>
|
||||
<circle cx="37" cy="37" r="2.5" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MaalFlows Preview</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
+101
-13
@@ -5,6 +5,8 @@
|
||||
const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase();
|
||||
const storageKey = `maalflows:${siteKey}`;
|
||||
const session = loadSession();
|
||||
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||||
const maxAttachmentBytes = 3_000_000;
|
||||
|
||||
fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`)
|
||||
.then((res) => res.json())
|
||||
@@ -34,11 +36,22 @@
|
||||
<div class="mf-messages"></div>
|
||||
<div class="mf-typing" hidden>Operator pise...</div>
|
||||
<form class="mf-form">
|
||||
<textarea name="message" required rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
|
||||
<div class="mf-actions">
|
||||
<input name="attachments" type="file" accept="image/*" multiple>
|
||||
<button type="submit">${escapeHtml(copy.sendLabel)}</button>
|
||||
<div class="mf-composer">
|
||||
<textarea name="message" required rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
|
||||
<button class="mf-send" type="submit" aria-label="${escapeHtml(copy.sendLabel)}" title="${escapeHtml(copy.sendLabel)}">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3.7 20.3 21 12 3.7 3.7l2.5 7.1L14 12l-7.8 1.2-2.5 7.1Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<label class="mf-dropzone" tabindex="0">
|
||||
<input name="attachments" type="file" accept=".jpg,.jpeg,.png,.webp,.gif,.avif,image/jpeg,image/png,image/webp,image/gif,image/avif" multiple>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 17.5V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5Zm3.3-1.2 2.9-3.4 2 2.4 2.7-3.2 3.1 4.2H7.3Zm1-6.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"></path>
|
||||
</svg>
|
||||
<span>Pridat fotku nebo pretahnout</span>
|
||||
</label>
|
||||
<div class="mf-attachment-list"></div>
|
||||
<p class="mf-note">Odeslanim zpravy nam predavate udaje potrebne pro odpoved.</p>
|
||||
</form>
|
||||
</section>
|
||||
@@ -50,6 +63,9 @@
|
||||
const form = root.querySelector(".mf-form");
|
||||
const messages = root.querySelector(".mf-messages");
|
||||
const typing = root.querySelector(".mf-typing");
|
||||
const dropzone = root.querySelector(".mf-dropzone");
|
||||
const attachmentList = root.querySelector(".mf-attachment-list");
|
||||
let selectedFiles = [];
|
||||
|
||||
launcher.addEventListener("click", () => panel.classList.toggle("open"));
|
||||
close.addEventListener("click", () => panel.classList.remove("open"));
|
||||
@@ -59,13 +75,53 @@
|
||||
fetch(`${apiBase}/api/widget/conversations/${session.conversationId}/typing`, { method: "POST" }).catch(() => {});
|
||||
}, 700));
|
||||
|
||||
form.attachments.addEventListener("change", () => {
|
||||
selectedFiles = mergeImageFiles(selectedFiles, [...form.attachments.files]);
|
||||
renderSelectedFiles(attachmentList, selectedFiles);
|
||||
form.attachments.value = "";
|
||||
});
|
||||
|
||||
dropzone.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
form.attachments.click();
|
||||
}
|
||||
});
|
||||
|
||||
for (const eventName of ["dragenter", "dragover"]) {
|
||||
dropzone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("dragging");
|
||||
});
|
||||
}
|
||||
|
||||
for (const eventName of ["dragleave", "drop"]) {
|
||||
dropzone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("dragging");
|
||||
});
|
||||
}
|
||||
|
||||
dropzone.addEventListener("drop", (event) => {
|
||||
selectedFiles = mergeImageFiles(selectedFiles, [...event.dataTransfer.files]);
|
||||
renderSelectedFiles(attachmentList, selectedFiles);
|
||||
});
|
||||
|
||||
attachmentList.addEventListener("click", (event) => {
|
||||
const removeButton = event.target.closest("[data-remove]");
|
||||
if (!removeButton) return;
|
||||
selectedFiles.splice(Number(removeButton.dataset.remove), 1);
|
||||
renderSelectedFiles(attachmentList, selectedFiles);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const body = await payloadFromForm(form);
|
||||
const body = await payloadFromForm(form, selectedFiles);
|
||||
if (!body.message.trim()) return;
|
||||
addMessage(messages, "visitor", body.message, []);
|
||||
form.message.value = "";
|
||||
form.attachments.value = "";
|
||||
selectedFiles = [];
|
||||
renderSelectedFiles(attachmentList, selectedFiles);
|
||||
|
||||
const url = session.conversationId
|
||||
? `${apiBase}/api/widget/conversations/${session.conversationId}/messages`
|
||||
@@ -97,12 +153,11 @@
|
||||
if (session.conversationId) connectEvents(messages, typing);
|
||||
}
|
||||
|
||||
async function payloadFromForm(form) {
|
||||
async function payloadFromForm(form, selectedFiles) {
|
||||
const fd = new FormData(form);
|
||||
const files = [...form.attachments.files].slice(0, 3);
|
||||
return {
|
||||
message: fd.get("message") || "",
|
||||
attachments: await Promise.all(files.map(fileToPayload))
|
||||
attachments: await Promise.all(selectedFiles.slice(0, 3).map(fileToPayload))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,11 +186,35 @@
|
||||
function addMessage(messages, sender, body, attachments) {
|
||||
const item = document.createElement("article");
|
||||
item.className = `mf-message ${sender}`;
|
||||
item.innerHTML = `<p>${escapeHtml(body)}</p>${attachments.map((a) => `<a href="${apiBase}${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).join("")}`;
|
||||
item.innerHTML = `<p>${escapeHtml(body)}</p>${attachments.map((a) => `<a class="mf-photo" href="${apiBase}${a.url}" target="_blank" rel="noreferrer"><img src="${apiBase}${a.url}" alt="${escapeHtml(a.name)}"></a>`).join("")}`;
|
||||
messages.append(item);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function mergeImageFiles(currentFiles, incomingFiles) {
|
||||
const nextFiles = [...currentFiles];
|
||||
for (const file of incomingFiles) {
|
||||
if (!isAllowedImageFile(file)) continue;
|
||||
const exists = nextFiles.some((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified);
|
||||
if (!exists) nextFiles.push(file);
|
||||
if (nextFiles.length === 3) break;
|
||||
}
|
||||
return nextFiles;
|
||||
}
|
||||
|
||||
function renderSelectedFiles(container, files) {
|
||||
container.innerHTML = files.map((file, index) => `
|
||||
<span class="mf-attachment-chip">
|
||||
<span>${escapeHtml(file.name)}</span>
|
||||
<button type="button" data-remove="${index}" aria-label="Odebrat fotku">×</button>
|
||||
</span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function isAllowedImageFile(file) {
|
||||
return allowedImageTypes.has(file.type) && file.size > 0 && file.size <= maxAttachmentBytes;
|
||||
}
|
||||
|
||||
function localized(settings, language) {
|
||||
const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || "";
|
||||
return {
|
||||
@@ -180,13 +259,22 @@
|
||||
.mf-message.visitor { align-self: flex-end; background: ${c.brand}; color: ${c.brandText}; }
|
||||
.mf-message p { margin: 0; white-space: pre-wrap; }
|
||||
.mf-message a { color: inherit; display: inline-block; margin-top: 6px; }
|
||||
.mf-photo img { display: block; width: min(180px, 100%); max-height: 130px; object-fit: cover; border-radius: 6px; }
|
||||
.mf-typing { padding: 0 14px 8px; color: ${c.muted}; font-size: 13px; }
|
||||
.mf-form { border-top: 1px solid #dfe6e2; padding: 12px; display: grid; gap: 9px; }
|
||||
.mf-composer { display: grid; grid-template-columns: minmax(0, 1fr) 42px; gap: 9px; align-items: end; }
|
||||
input, textarea { width: 100%; border: 1px solid #dfe6e2; border-radius: 6px; padding: 9px 10px; color: ${c.text}; background: white; }
|
||||
textarea { resize: vertical; min-height: 72px; }
|
||||
.mf-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 9px; align-items: center; }
|
||||
.mf-actions button { border: 0; border-radius: 6px; background: ${c.brand}; color: ${c.brandText}; padding: 10px 13px; cursor: pointer; }
|
||||
input[type=file] { font-size: 12px; padding: 7px; }
|
||||
.mf-send { width: 42px; height: 42px; display: grid; place-items: center; border: 0; border-radius: 50%; background: ${c.brand}; color: ${c.brandText}; cursor: pointer; }
|
||||
.mf-send svg { width: 22px; height: 22px; fill: currentColor; transform: translateX(1px); }
|
||||
.mf-dropzone { min-height: 42px; border: 1px dashed #b9c9c1; border-radius: 8px; display: flex; align-items: center; gap: 8px; padding: 9px 10px; color: ${c.muted}; background: #f8fbf9; font-size: 12px; cursor: pointer; }
|
||||
.mf-dropzone.dragging { border-color: ${c.brand}; background: #eef8f4; color: ${c.text}; }
|
||||
.mf-dropzone input { display: none; }
|
||||
.mf-dropzone svg { width: 18px; height: 18px; fill: currentColor; flex: 0 0 auto; }
|
||||
.mf-attachment-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.mf-attachment-chip { max-width: 100%; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #dfe6e2; border-radius: 999px; background: #f8fbf9; color: ${c.text}; padding: 5px 5px 5px 9px; font-size: 12px; }
|
||||
.mf-attachment-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 210px; }
|
||||
.mf-attachment-chip button { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #e8eee9; color: ${c.text}; cursor: pointer; padding: 0; }
|
||||
.mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; }
|
||||
@media (max-width: 640px) {
|
||||
.mf-launcher { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx}px; }
|
||||
|
||||
@@ -16,6 +16,9 @@ const PORT = Number(process.env.PORT || 3400);
|
||||
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, "");
|
||||
const AUTH_SECRET = process.env.AUTH_SECRET || "dev-secret-change-me";
|
||||
const COOKIE_NAME = "mf_session";
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||||
const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
|
||||
const MAX_ATTACHMENT_BYTES = 3_000_000;
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
@@ -47,6 +50,7 @@ async function route(req, res) {
|
||||
|
||||
if (req.method === "OPTIONS") return endCors(res);
|
||||
if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true });
|
||||
if (req.method === "GET" && url.pathname === "/favicon.svg") return file(res, path.join(PUBLIC_DIR, "favicon.svg"), "image/svg+xml; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname === "/admin") return file(res, path.join(PUBLIC_DIR, "admin.html"), "text/html; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname === "/preview") return file(res, path.join(PUBLIC_DIR, "preview.html"), "text/html; charset=utf-8");
|
||||
if (req.method === "GET" && url.pathname.startsWith("/uploads/")) return serveUpload(res, url.pathname);
|
||||
@@ -301,8 +305,9 @@ function widgetConfig(res, url) {
|
||||
}
|
||||
|
||||
async function createConversation(req, res) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
const body = await readJson(req, 8_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
|
||||
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
|
||||
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||
|
||||
@@ -349,8 +354,9 @@ async function createConversation(req, res) {
|
||||
}
|
||||
|
||||
async function createVisitorMessage(req, res, url) {
|
||||
const body = await readJson(req, 2_000_000);
|
||||
const body = await readJson(req, 8_000_000);
|
||||
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" });
|
||||
if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" });
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim());
|
||||
@@ -427,7 +433,7 @@ function saveAttachments(conversationId, messageId, attachments) {
|
||||
for (const attachment of attachments.slice(0, 3)) {
|
||||
if (!attachment.dataBase64 || !attachment.name) continue;
|
||||
const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64");
|
||||
if (!buffer.length || buffer.length > 3_000_000) continue;
|
||||
if (!isAllowedImageAttachment(attachment, buffer)) continue;
|
||||
const safeName = path.basename(attachment.name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
||||
const filename = `${Date.now()}-${crypto.randomBytes(6).toString("hex")}-${safeName}`;
|
||||
const storagePath = path.join(UPLOAD_DIR, filename);
|
||||
@@ -558,7 +564,8 @@ function servePublic(res, pathname) {
|
||||
|
||||
function serveUpload(res, pathname) {
|
||||
const target = path.join(UPLOAD_DIR, path.basename(pathname));
|
||||
return file(res, target, "application/octet-stream");
|
||||
const attachment = db.prepare("SELECT mime_type FROM attachments WHERE public_url = ?").get(`/uploads/${path.basename(pathname)}`);
|
||||
return file(res, target, attachment?.mime_type || "application/octet-stream");
|
||||
}
|
||||
|
||||
function file(res, target, contentType) {
|
||||
@@ -651,6 +658,31 @@ function countryFromHeaders(req) {
|
||||
return req.headers["cf-ipcountry"] || req.headers["x-vercel-ip-country"] || null;
|
||||
}
|
||||
|
||||
function isAllowedImageAttachment(attachment, buffer) {
|
||||
const extension = path.extname(attachment.name || "").toLowerCase();
|
||||
const type = String(attachment.type || "").toLowerCase();
|
||||
if (!buffer.length || buffer.length > MAX_ATTACHMENT_BYTES) return false;
|
||||
if (!ALLOWED_IMAGE_TYPES.has(type) || !ALLOWED_IMAGE_EXTENSIONS.has(extension)) return false;
|
||||
return hasExpectedImageSignature(type, buffer);
|
||||
}
|
||||
|
||||
function attachmentsAreAllowed(attachments) {
|
||||
return attachments.every((attachment) => {
|
||||
if (!attachment.dataBase64 || !attachment.name) return true;
|
||||
const buffer = Buffer.from(String(attachment.dataBase64).split(",").pop(), "base64");
|
||||
return isAllowedImageAttachment(attachment, buffer);
|
||||
});
|
||||
}
|
||||
|
||||
function hasExpectedImageSignature(type, buffer) {
|
||||
if (type === "image/jpeg") return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
if (type === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
if (type === "image/gif") return buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a";
|
||||
if (type === "image/webp") return buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
|
||||
if (type === "image/avif") return buffer.subarray(4, 12).toString("ascii").includes("ftyp");
|
||||
return false;
|
||||
}
|
||||
|
||||
function loadDotEnv(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
|
||||
Reference in New Issue
Block a user