diff --git a/.gitignore b/.gitignore index 0ba3a72..5889314 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ sessions/ *.sqlite-* *.sqlite3 *.sqlite3-* +.idea/ diff --git a/public/js/api.js b/public/js/api.js index d2b6aed..d4532df 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -1,180 +1,22 @@ -/** JSON requests return error records; streaming requests reject unless onError handles them. */ +import {createSseParser, parseSseData, SseParseError} from "./sse.js"; + export class ApiError extends Error { - constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) { - super(message); - this.name = "ApiError"; - this.status = status; - this.code = code; - this.requestId = requestId; - this.details = details; - } - - toJSON() { - return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId}; - } -} - -function requestScope({signal, timeoutMs = 30000} = {}) { - const controller = new AbortController(); - const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"})); - if (signal?.aborted) abort(); - else signal?.addEventListener("abort", abort, {once: true}); - const timer = timeoutMs > 0 ? setTimeout(() => { - controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"})); - }, timeoutMs) : null; - return { - signal: controller.signal, - close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); } - }; -} - -// Also bound body reads, even after fetch has resolved its response headers. -function abortable(operation, signal) { - if (signal.aborted) return Promise.reject(signal.reason); - return new Promise((resolve, reject) => { - const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); }; - signal.addEventListener("abort", abort, {once: true}); - try { - Promise.resolve(operation()).then(resolve, reject) - .finally(() => signal.removeEventListener("abort", abort)); - } catch (error) { - signal.removeEventListener("abort", abort); - reject(error); - } - }); -} - -function responseError(response, payload = {}, fallback = "", code = "") { - const details = payload && typeof payload === "object" ? payload : {}; - const error = details.error; - return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), { - ...details, - status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status, - code: details.code || error?.code || code || `HTTP_${response.status}`, - requestId: details.requestId || response.headers.get("X-Request-ID") || null - }); -} - -async function readJson(response, signal) { - const text = await abortable(() => response.text(), signal); - let payload; - if (text.trim()) { - try { payload = JSON.parse(text); } - catch { - if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); - } - } - if (!response.ok) throw responseError(response, payload); - return payload ?? {}; -} - -function normalizeError(error, scope, response) { - const failure = scope.signal.aborted ? scope.signal.reason : error; - if (failure instanceof ApiError) { - if (!failure.status && response) failure.status = response.status; - failure.requestId ||= response?.headers.get("X-Request-ID") || null; - return failure; - } - return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", { - status: response?.status || 0, - code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR", - requestId: response?.headers.get("X-Request-ID") || null - }); -} - -async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) { - const scope = requestScope(options); - let response; - try { - response = await abortable(() => fetch(url, { - method, - credentials: "same-origin", - headers, - body, - signal: scope.signal - }), scope.signal); - if (!asBlob || !response.ok) return await readJson(response, scope.signal); - return {blob: await abortable(() => response.blob(), scope.signal)}; - } catch (error) { - return normalizeError(error, scope, response).toJSON(); - } finally { - scope.close(); - } -} - -export function request(url, data, method = "POST", options = {}) { - return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"}, - body: data == null ? undefined : JSON.stringify(data)}, options); -} - -// Let fetch set the multipart boundary; share deadlines, cancellation and errors. -export const multipart = (url, form, options = {}) => bufferedRequest(url, - {method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options}); -export const download = (url, options = {}) => bufferedRequest(url, - {method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true); - -export const get = (url, options) => request(url, null, "GET", options); -export const post = (url, data, options) => request(url, data, "POST", options); -export const put = (url, data, options) => request(url, data, "PUT", options); -export const del = (url, options) => request(url, null, "DELETE", options); - -/** - * SSE text events carry {chunk}; corrections carry {target: "reply"|"thinking", text}. - * A legacy AbortSignal is also accepted as the fourth argument. Set timeoutMs: 0 to disable the deadline. - */ -export async function stream(url, data, handlers = {}, options = {}) { - if (options && typeof options.addEventListener === "function") options = {signal: options}; - const scope = requestScope({timeoutMs: 660000, ...options}); - let response, reader; - try { - response = await abortable(() => fetch(url, { - method: "POST", - credentials: "same-origin", - headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"}, - body: JSON.stringify(data || {}), - signal: scope.signal - }), scope.signal); - if (!response.ok) await readJson(response, scope.signal); - if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE"); - reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let terminal = false; - while (!terminal) { - const {value, done} = await abortable(() => reader.read(), scope.signal); - if (done) break; - buffer += decoder.decode(value, {stream: true}); - const parts = buffer.split(/\r?\n\r?\n/); - buffer = parts.pop() || ""; - for (const part of parts) { - const lines = part.split(/\r?\n/); - const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim(); - const dataLines = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()); - if (!event || !dataLines.length) continue; - const payload = JSON.parse(dataLines.join("\n")); - if (!payload || typeof payload !== "object") throw new Error("Invalid response stream."); - const textFields = {status: "status", done: "reply", error: "error", replace: "text"}; - if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream."); - if (event === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new Error("Invalid response stream."); - if (event === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new Error("Invalid response stream."); - if (event === "replace" && !["reply", "thinking"].includes(payload.target)) throw new Error("Invalid replacement stream."); - if (event === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR"); - const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[event]; - if (handler) handlers[handler]?.(payload); - if (event === "done") { terminal = true; break; } - } - } - if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED"); - } catch (error) { - const failure = normalizeError(error, scope, response); - if (handlers.onError) handlers.onError(failure.toJSON()); - else throw failure; - } finally { - scope.close(); - if (reader) { - // Cancelling a custom or stalled source must not prevent the request from settling. - void reader.cancel().catch(() => {}); - reader.releaseLock(); - } - } + constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.requestId = requestId; this.details = details; } + toJSON() { return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId}; } } +function requestScope({signal, timeoutMs = 30000} = {}) { const controller = new AbortController(); const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"})); if (signal?.aborted) abort(); else signal?.addEventListener("abort", abort, {once: true}); const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"})), timeoutMs) : null; return {signal: controller.signal, close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); }}; } +function abortable(operation, signal) { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); }; signal.addEventListener("abort", abort, {once: true}); Promise.resolve().then(operation).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); }); } +function responseError(response, payload = {}, fallback = "", code = "") { const details = payload && typeof payload === "object" ? payload : {}; const error = details.error; return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), {...details, status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status, code: details.code || error?.code || code || `HTTP_${response.status}`, requestId: details.requestId || response.headers.get("X-Request-ID") || null}); } +async function readJson(response, signal) { const text = await abortable(() => response.text(), signal); let payload; if (text.trim()) { try { payload = JSON.parse(text); } catch { if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); } } if (!response.ok) throw responseError(response, payload); return payload ?? {}; } +function normalizeError(error, scope, response) { const failure = scope.signal.aborted ? scope.signal.reason : error; if (failure instanceof ApiError) { if (!failure.status && response) failure.status = response.status; failure.requestId ||= response?.headers.get("X-Request-ID") || null; return failure; } return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", {status: response?.status || 0, code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR", requestId: response?.headers.get("X-Request-ID") || null}); } +const transient = e => e?.status === 408 || e?.status === 425 || e?.status === 429 || e?.status >= 500 || e?.code === "NETWORK_ERROR" || e?.code === "TIMEOUT"; +const pause = (ms, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, {once: true}); }); +function filenameFromDisposition(value) { const encoded = value?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const plain = value?.match(/filename="?([^";]+)"?/i)?.[1]; try { return decodeURIComponent(encoded || plain || ""); } catch { return plain || null; } } +async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) { const scope = requestScope(options); const maxRetries = method === "GET" ? Math.max(0, options.retries ?? 2) : 0; try { for (let attempt = 0; ; attempt += 1) { let response; try { response = await abortable(() => fetch(url, {method, credentials: "same-origin", headers, body, signal: scope.signal}), scope.signal); if (!asBlob || !response.ok) return await readJson(response, scope.signal); const blob = await abortable(() => response.blob(), scope.signal); return {blob, filename: filenameFromDisposition(response.headers.get("Content-Disposition")), contentType: response.headers.get("Content-Type") || blob.type || "application/octet-stream", size: Number(response.headers.get("Content-Length")) || blob.size}; } catch (error) { const failure = normalizeError(error, scope, response); if (attempt < maxRetries && transient(failure) && !scope.signal.aborted) { await pause(Math.min(8000, 250 * 2 ** attempt), scope.signal); continue; } return failure.toJSON(); } } } finally { scope.close(); } } +export function request(url, data, method = "POST", options = {}) { return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"}, body: data == null ? undefined : JSON.stringify(data)}, options); } +export async function requestResult(url, data, method = "POST", options = {}) { const value = await request(url, data, method, options); return value?.error ? {ok: false, data: null, error: new ApiError(value.error, value)} : {ok: true, data: value, error: null}; } +export async function requestOrThrow(url, data, method = "POST", options = {}) { const result = await requestResult(url, data, method, options); if (!result.ok) throw result.error; return result.data; } +export const multipart = (url, form, options = {}) => bufferedRequest(url, {method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options}); +export const download = (url, options = {}) => bufferedRequest(url, {method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true); +export const get = (url, options) => request(url, null, "GET", options); export const post = (url, data, options) => request(url, data, "POST", options); export const put = (url, data, options) => request(url, data, "PUT", options); export const del = (url, options) => request(url, null, "DELETE", options); +export async function stream(url, data, handlers = {}, options = {}) { if (options && typeof options.addEventListener === "function") options = {signal: options}; const scope = requestScope({timeoutMs: 660000, ...options}); let response; let reader; let terminal = false; try { response = await abortable(() => fetch(url, {method: "POST", credentials: "same-origin", headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"}, body: JSON.stringify(data || {}), signal: scope.signal}), scope.signal); if (!response.ok) await readJson(response, scope.signal); if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE"); reader = response.body.getReader(); const parser = createSseParser({onComment: handlers.onComment, onRetry: handlers.onRetry}); const decoder = new TextDecoder(); const consume = events => { for (const event of events) { let payload; try { payload = parseSseData(event); } catch (error) { throw new SseParseError(error.message, {cause: error, event}); } if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new SseParseError("Invalid response stream.", {event}); const name = event.event; const fields = {status: "status", done: "reply", error: "error", replace: "text"}; if (fields[name] && typeof payload[fields[name]] !== "string") throw new SseParseError(`Invalid ${name} event payload.`, {event}); if (name === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new SseParseError("Invalid chunk event payload.", {event}); if (name === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new SseParseError("Invalid thinking event payload.", {event}); if (name === "replace" && !["reply", "thinking"].includes(payload.target)) throw new SseParseError("Invalid replacement stream.", {event}); if (name === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR"); const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[name]; if (handler) handlers[handler]?.(payload); else if (name !== "message") handlers.onUnknown?.(event, payload); if (name === "done") { terminal = true; break; } } }; while (!terminal) { const {value, done} = await abortable(() => reader.read(), scope.signal); if (done) break; consume(parser.push(decoder.decode(value, {stream: true}))); } consume(parser.finish()); if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED"); } catch (error) { const failure = normalizeError(error, scope, response); const reconnectAttempt = options._reconnectAttempt || 0; if (options.reconnect === true && reconnectAttempt < (options.maxReconnects ?? 3) && transient(failure) && !scope.signal.aborted) { await pause(Math.min(8000, options.reconnectDelayMs ?? 500 * 2 ** reconnectAttempt), scope.signal); return stream(url, data, handlers, {...options, _reconnectAttempt: reconnectAttempt + 1}); } if (handlers.onError) handlers.onError(failure.toJSON()); else throw failure; } finally { scope.close(); if (reader) { void reader.cancel().catch(() => {}); reader.releaseLock(); } } } diff --git a/public/js/sse.js b/public/js/sse.js new file mode 100644 index 0000000..6d884c3 --- /dev/null +++ b/public/js/sse.js @@ -0,0 +1,50 @@ +/** Reusable Server-Sent Events parser for browser and test environments. */ +export class SseParseError extends Error { + constructor(message, details = {}) { + super(message); + this.name = "SseParseError"; + Object.assign(this, details); + } +} + +function parseEvent(block, {onComment, onRetry} = {}) { + if (!block) return null; + let event = "message"; + const data = []; + let id; + let retry; + for (const line of block.split(/\r?\n/)) { + if (line.startsWith(":")) { onComment?.(line.slice(1).trimStart()); continue; } + const separator = line.indexOf(":"); + const field = separator < 0 ? line : line.slice(0, separator); + const value = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, ""); + if (field === "event") event = value; + else if (field === "data") data.push(value); + else if (field === "id" && !value.includes("\u0000")) id = value; + else if (field === "retry" && /^\d+$/.test(value)) { retry = Number(value); onRetry?.(retry); } + } + if (!data.length) return null; + return {event, data: data.join("\n"), ...(id !== undefined ? {id} : {}), ...(retry !== undefined ? {retry} : {})}; +} + +export function createSseParser(options = {}) { + let buffer = ""; + return { + push(text) { + buffer += text; + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = blocks.pop() || ""; + return blocks.map(block => parseEvent(block, options)).filter(Boolean); + }, + finish() { + const event = parseEvent(buffer, options); + buffer = ""; + return event ? [event] : []; + } + }; +} + +export function parseSseData(event) { + try { return JSON.parse(event.data); } + catch (error) { throw new SseParseError("Malformed SSE JSON payload.", {cause: error, event}); } +} diff --git a/src/app.js b/src/app.js index 28aa7ce..d7b1ae6 100644 --- a/src/app.js +++ b/src/app.js @@ -220,6 +220,17 @@ function sameOriginProtection(config) { }; } +function browserSecurityHeaders(config) { + return (_req, res, next) => { + res.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; worker-src 'self' blob:"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (process.env.NODE_ENV === "production" && config.cookieSecure) res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload"); + next(); + }; +} + function enforceSessionVersion(repositories) { return (req, res, next) => { if (!req.session.user) return next(); @@ -249,6 +260,7 @@ export function createApp(options = {}) { if (config.dbPath !== ":memory:") { fs.mkdirSync(config.dataDir, {recursive: true}); fs.mkdirSync(config.sessionsDir, {recursive: true}); + fs.mkdirSync(config.uploadTmpDir, {recursive: true}); } const closeHandlers = createLifecycleMonitors(app, config); @@ -271,6 +283,7 @@ export function createApp(options = {}) { ); app.use(observability.middleware); + app.use(browserSecurityHeaders(config)); app.use(createHealthRouter({modelService})); app.use(express.json({limit: "1mb"})); app.use(session({ diff --git a/src/config.js b/src/config.js index 4a8ae77..73e957e 100644 --- a/src/config.js +++ b/src/config.js @@ -33,6 +33,7 @@ export function createConfig(overrides = {}) { publicDir: path.resolve(rootDir, "public"), dataDir, uploadDir: process.env.UPLOAD_DIR || (process.env.NODE_ENV === "production" ? "/app/data/uploads" : path.resolve(dataDir, "uploads")), + uploadTmpDir: process.env.UPLOAD_TMP_DIR || path.resolve(dataDir, "upload-tmp"), uploadMaxSizeMb: Math.max(1, int(process.env.UPLOAD_MAX_SIZE_MB, 10)), uploadMaxFiles: Math.max(1, int(process.env.UPLOAD_MAX_FILES, 5)), uploadMaxPixels: Math.max(1, int(process.env.UPLOAD_MAX_PIXELS, 30000000)), diff --git a/src/routes/uploads.js b/src/routes/uploads.js index 45742dd..a24c2f4 100644 --- a/src/routes/uploads.js +++ b/src/routes/uploads.js @@ -2,6 +2,8 @@ * @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle. */ import express from "express"; +import {mkdirSync} from "node:fs"; +import {unlink} from "node:fs/promises"; import {asyncHandler} from "../middleware/asyncHandler.js"; import multer from "multer"; import {requireLogin} from "../middleware/auth.js"; @@ -9,8 +11,9 @@ import {AttachmentError} from "../services/attachmentService.js"; export function createUploadsRouter(service, config) { const router = express.Router(); + mkdirSync(config.uploadTmpDir, {recursive: true}); const activeUploads = new Map(); - const upload = multer({storage: multer.memoryStorage(), limits: { + const upload = multer({storage: multer.diskStorage({destination: config.uploadTmpDir}), limits: { fileSize: config.uploadMaxSizeMb * 1024 * 1024, files: 1, fields: 0, parts: 1 }}).single("image"); const fail = (res, error) => res.status(error instanceof AttachmentError ? error.status : 500).json({error: @@ -26,8 +29,9 @@ export function createUploadsRouter(service, config) { if (remaining) activeUploads.set(user, remaining); else activeUploads.delete(user); }); upload(req, res, async error => { - if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: + if (error) { if (req.file?.path) await unlink(req.file.path).catch(() => {}); return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: error.code === "LIMIT_FILE_SIZE" ? `Image exceeds ${config.uploadMaxSizeMb} MB` : "Upload one PNG, JPEG or WebP image per request"}); + } try { res.status(201).json(await service.upload(req.file, req.session.user)); } catch (failure) { fail(res, failure); } }); diff --git a/src/services/attachmentService.js b/src/services/attachmentService.js index 17a0b04..351fd29 100644 --- a/src/services/attachmentService.js +++ b/src/services/attachmentService.js @@ -2,6 +2,7 @@ * @file Validiert und speichert Bildanhänge und bereitet zwischengespeicherte Bildanalysen für den Chat vor. */ import sharp from "sharp"; +import {readFile, unlink} from "node:fs/promises"; import {createHash, randomUUID} from "node:crypto"; import {attachmentMetadata} from "../db/attachments.js"; import {LocalFileStorage} from "./localFileStorage.js"; @@ -164,26 +165,29 @@ export function createAttachmentService(db, config, modelService, visionDebugSer }, async upload(file, user, {forImport = false} = {}) { await cleanup(); - if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image"); - if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413); + let buffer; + try { buffer = file?.buffer || (file?.path ? await readFile(file.path) : null); } + finally { if (file?.path) await unlink(file.path).catch(() => {}); } + if (!buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image"); + if (buffer.length > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413); let info; try { - const image = sharp(file.buffer, {limitInputPixels: config.uploadMaxPixels, failOn: "warning"}); + const image = sharp(buffer, {limitInputPixels: config.uploadMaxPixels, failOn: "warning"}); info = await image.metadata(); if (!["png", "jpeg", "webp"].includes(info.format) || (info.pages || 1) > 1) throw new Error("Unsupported"); await image.stats(); // Decode pixels, not just headers: reject truncated/malformed images. } catch { throw new AttachmentError("Invalid image. Use a non-animated PNG, JPEG or WebP within the pixel limit."); } - const {storageKey} = await storage.save({buffer: file.buffer, extension: info.format}).catch(() => { throw new AttachmentError("Image could not be saved. Please retry.", 503); }); + const {storageKey} = await storage.save({buffer, extension: info.format}).catch(() => { throw new AttachmentError("Image could not be saved. Please retry.", 503); }); const id = randomUUID(); try { // Bound abandoned uploads per account as well as per submitted message. db.transaction(() => { if (!forImport && db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first."); db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user, - [...String(file.originalname || "image").replace(/[\\/]/g, "_")].map(character => character.charCodeAt(0) < 32 ? "_" : character).join("").slice(0, 255), storageKey, `image/${info.format}`, file.size, info.width, info.height); + [...String(file.originalname || "image").replace(/[\\/]/g, "_")].map(character => character.charCodeAt(0) < 32 ? "_" : character).join("").slice(0, 255), storageKey, `image/${info.format}`, buffer.length, info.width, info.height); })(); } catch (error) { await storage.delete(storageKey); throw error; } - log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`}); + log("attachmentUploadSuccess", {id, size: buffer.length, mime: `image/${info.format}`}); return attachmentMetadata(get(id)); }, createMessage(ids, user, chatId, insert, reuseChatId = null) {