From f069b41df6da1cfdf1c938534e0d210f10a24588 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 27 Mar 2026 16:40:23 +0200 Subject: [PATCH] chore(standalone): upload middleware with error handling --- .../src/lightweight/browser_router.ts | 37 +++++++++++++++++-- .../src/lightweight/browser_routes.ts | 5 ++- apps/server/src/routes/routes.ts | 4 +- .../trilium-core/src/routes/api/import.ts | 11 ++++-- packages/trilium-core/src/routes/index.ts | 4 +- .../src/services/import/common.ts | 2 +- .../trilium-core/src/services/import/opml.ts | 2 +- 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/apps/client-standalone/src/lightweight/browser_router.ts b/apps/client-standalone/src/lightweight/browser_router.ts index caa698f0e5..a5bdfea79f 100644 --- a/apps/client-standalone/src/lightweight/browser_router.ts +++ b/apps/client-standalone/src/lightweight/browser_router.ts @@ -5,6 +5,12 @@ import { getContext, routes } from "@triliumnext/core"; +export interface UploadedFile { + originalname: string; + mimetype: string; + buffer: Uint8Array; +} + export interface BrowserRequest { method: string; url: string; @@ -13,6 +19,7 @@ export interface BrowserRequest { query: Record; headers?: Record; body?: unknown; + file?: UploadedFile; } export interface BrowserResponse { @@ -154,8 +161,9 @@ export class BrowserRouter { const query = parseQuery(url.search); const upperMethod = method.toUpperCase(); - // Parse JSON body if it's an ArrayBuffer and content-type suggests JSON + // Parse body based on content-type let parsedBody = body; + let uploadedFile: UploadedFile | undefined; if (body instanceof ArrayBuffer && headers) { const contentType = headers['content-type'] || headers['Content-Type'] || ''; if (contentType.includes('application/json')) { @@ -166,9 +174,31 @@ export class BrowserRouter { } } catch (e) { console.warn('[Router] Failed to parse JSON body:', e); - // Keep original body if JSON parsing fails parsedBody = body; } + } else if (contentType.includes('multipart/form-data')) { + try { + // Reconstruct a Response so we can use the native FormData parser + const response = new Response(body, { headers: { 'content-type': contentType } }); + const formData = await response.formData(); + const formFields: Record = {}; + for (const [key, value] of formData.entries()) { + if (typeof value === 'string') { + formFields[key] = value; + } else { + // File field (Blob) — multer uses the field name "upload" + const fileBuffer = new Uint8Array(await value.arrayBuffer()); + uploadedFile = { + originalname: value.name, + mimetype: value.type || 'application/octet-stream', + buffer: fileBuffer + }; + } + } + parsedBody = formFields; + } catch (e) { + console.warn('[Router] Failed to parse multipart body:', e); + } } } // Find matching route @@ -191,7 +221,8 @@ export class BrowserRouter { params, query, headers: headers ?? {}, - body: parsedBody + body: parsedBody, + file: uploadedFile }; try { diff --git a/apps/client-standalone/src/lightweight/browser_routes.ts b/apps/client-standalone/src/lightweight/browser_routes.ts index 8eed93c4ea..e6492f7462 100644 --- a/apps/client-standalone/src/lightweight/browser_routes.ts +++ b/apps/client-standalone/src/lightweight/browser_routes.ts @@ -35,6 +35,7 @@ function toExpressLikeReq(req: BrowserRequest) { body: req.body, headers: req.headers ?? {}, method: req.method, + file: req.file, get originalUrl() { return req.url; } }; } @@ -227,7 +228,9 @@ export function registerRoutes(router: BrowserRouter): void { checkApiAuthOrElectron: noopMiddleware, checkAppNotInitialized, checkCredentials: noopMiddleware, - loginRateLimiter: noopMiddleware + loginRateLimiter: noopMiddleware, + uploadMiddlewareWithErrorHandling: noopMiddleware, + csrfMiddleware: noopMiddleware }); apiRoute('get', '/bootstrap', bootstrapRoute); diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 3bc8bf3cb3..b34a610957 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -88,7 +88,9 @@ function register(app: express.Application) { checkApiAuthOrElectron: auth.checkApiAuthOrElectron, checkAppNotInitialized: auth.checkAppNotInitialized, checkCredentials: auth.checkCredentials, - loginRateLimiter + loginRateLimiter, + uploadMiddlewareWithErrorHandling, + csrfMiddleware }); route(PUT, "/api/notes/:noteId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateFile, apiResultHandler); diff --git a/packages/trilium-core/src/routes/api/import.ts b/packages/trilium-core/src/routes/api/import.ts index 923520887f..4d68e504f4 100644 --- a/packages/trilium-core/src/routes/api/import.ts +++ b/packages/trilium-core/src/routes/api/import.ts @@ -1,5 +1,10 @@ import type { Request } from "express"; import path from "path"; +import type { File } from "../../services/import/common.js"; + +interface ImportRequest

extends Request

{ + file?: File; +} import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; @@ -14,7 +19,7 @@ import * as cls from "../../services/context.js"; import { ValidationError } from "../../errors.js"; import becca_loader from "../../becca/becca_loader.js"; -async function importNotesToBranch(req: Request<{ parentNoteId: string }>) { +async function importNotesToBranch(req: ImportRequest<{ parentNoteId: string }>) { const { parentNoteId } = req.params; const { taskId, last } = req.body; @@ -66,7 +71,7 @@ async function importNotesToBranch(req: Request<{ parentNoteId: string }>) { return importResult; } } else { - note = await singleImportService.importSingleFile(taskContext, file, parentNote); + note = singleImportService.importSingleFile(taskContext, file, parentNote); } } catch (e: unknown) { const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); @@ -100,7 +105,7 @@ async function importNotesToBranch(req: Request<{ parentNoteId: string }>) { return note.getPojo(); } -function importAttachmentsToNote(req: Request<{ parentNoteId: string }>) { +function importAttachmentsToNote(req: ImportRequest<{ parentNoteId: string }>) { const { parentNoteId } = req.params; const { taskId, last } = req.body; diff --git a/packages/trilium-core/src/routes/index.ts b/packages/trilium-core/src/routes/index.ts index 8f1ae2de27..f5e9e3e6c7 100644 --- a/packages/trilium-core/src/routes/index.ts +++ b/packages/trilium-core/src/routes/index.ts @@ -45,9 +45,11 @@ interface SharedApiRoutesContext { checkAppNotInitialized: any; loginRateLimiter: any; checkCredentials: any; + uploadMiddlewareWithErrorHandling: any; + csrfMiddleware: any; } -export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler, checkApiAuthOrElectron, checkAppNotInitialized, checkCredentials, loginRateLimiter }: SharedApiRoutesContext) { +export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler, checkApiAuthOrElectron, checkAppNotInitialized, checkCredentials, loginRateLimiter, uploadMiddlewareWithErrorHandling, csrfMiddleware }: SharedApiRoutesContext) { apiRoute(GET, '/api/tree', treeApiRoute.getTree); apiRoute(PST, '/api/tree/load', treeApiRoute.load); diff --git a/packages/trilium-core/src/services/import/common.ts b/packages/trilium-core/src/services/import/common.ts index 72b5eff4df..114f529458 100644 --- a/packages/trilium-core/src/services/import/common.ts +++ b/packages/trilium-core/src/services/import/common.ts @@ -1,5 +1,5 @@ export interface File { originalname: string; mimetype: string; - buffer: string | Buffer; + buffer: string | Buffer | Uint8Array; } diff --git a/packages/trilium-core/src/services/import/opml.ts b/packages/trilium-core/src/services/import/opml.ts index f44371e95e..370d150f5c 100644 --- a/packages/trilium-core/src/services/import/opml.ts +++ b/packages/trilium-core/src/services/import/opml.ts @@ -27,7 +27,7 @@ interface OpmlOutline { outline: OpmlOutline[]; } -async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: string | Buffer, parentNote: BNote) { +async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: string | Uint8Array, parentNote: BNote) { const xml = await new Promise((resolve, reject) => { parseString(fileBuffer, (err: any, result: OpmlXml) => { if (err) {