Improve image uploads and previews
This commit is contained in:
@@ -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