From a259b650859e408fef02c4c452f0ffb838ae32ce Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Mar 2026 17:04:17 +0200 Subject: [PATCH] feat(core): port image route --- .../src/lightweight/browser_router.ts | 27 ++++++++ .../src/lightweight/browser_routes.ts | 62 ++++++++++++++++++- apps/server/src/routes/routes.ts | 12 ++-- .../src/routes/api/image.spec.ts | 0 .../trilium-core}/src/routes/api/image.ts | 8 +-- packages/trilium-core/src/routes/index.ts | 8 ++- 6 files changed, 102 insertions(+), 15 deletions(-) rename {apps/server => packages/trilium-core}/src/routes/api/image.spec.ts (100%) rename {apps/server => packages/trilium-core}/src/routes/api/image.ts (94%) diff --git a/apps/client-standalone/src/lightweight/browser_router.ts b/apps/client-standalone/src/lightweight/browser_router.ts index d41b70fb93..caa698f0e5 100644 --- a/apps/client-standalone/src/lightweight/browser_router.ts +++ b/apps/client-standalone/src/lightweight/browser_router.ts @@ -30,6 +30,13 @@ interface Route { handler: RouteHandler; } +/** + * Symbol used to mark a result as an already-formatted response, + * so that formatResult passes it through without JSON-serializing. + * Must match the symbol exported from browser_routes.ts. + */ +const RAW_RESPONSE = Symbol.for('RAW_RESPONSE'); + const encoder = new TextEncoder(); /** @@ -204,6 +211,26 @@ export class BrowserRouter { * Follows the same patterns as the server's apiResultHandler. */ private formatResult(result: unknown): BrowserResponse { + // Handle raw responses (e.g. from image routes that write directly to res) + if (result && typeof result === 'object' && RAW_RESPONSE in result) { + const raw = result as unknown as { status: number; headers: Record; body: unknown }; + let body: ArrayBuffer | null = null; + + if (raw.body instanceof ArrayBuffer) { + body = raw.body; + } else if (raw.body instanceof Uint8Array) { + body = raw.body.buffer as ArrayBuffer; + } else if (typeof raw.body === 'string') { + body = encoder.encode(raw.body).buffer as ArrayBuffer; + } + + return { + status: raw.status, + headers: raw.headers, + body + }; + } + // Handle [statusCode, response] format if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) { const [statusCode, response] = result; diff --git a/apps/client-standalone/src/lightweight/browser_routes.ts b/apps/client-standalone/src/lightweight/browser_routes.ts index 8cce05152e..d4e5d9bdde 100644 --- a/apps/client-standalone/src/lightweight/browser_routes.ts +++ b/apps/client-standalone/src/lightweight/browser_routes.ts @@ -16,6 +16,13 @@ interface ResultHandlerResponse { setHeader(name: string, value: string): void; } +/** + * Symbol used to mark a result as an already-formatted BrowserResponse, + * so that BrowserRouter.formatResult passes it through without JSON-serializing. + * Uses Symbol.for() so the same symbol is shared across modules. + */ +const RAW_RESPONSE = Symbol.for('RAW_RESPONSE'); + type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; /** @@ -82,12 +89,24 @@ function createApiRoute(router: BrowserRouter, transactional: boolean) { * - The resultHandler is applied to post-process the result (entity conversion, status codes). */ function createRoute(router: BrowserRouter) { - return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any) => unknown, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => { + return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any, res: any) => unknown, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => { router.register(method, path, (req: BrowserRequest) => { return getContext().init(() => { setContextFromHeaders(req); const expressLikeReq = toExpressLikeReq(req); - const result = getSql().transactional(() => handler(expressLikeReq)); + const mockRes = createMockExpressResponse(); + const result = getSql().transactional(() => handler(expressLikeReq, mockRes)); + + // If the handler used the mock response (e.g. image routes that call res.send()), + // return it as a raw response so BrowserRouter doesn't JSON-serialize it. + if (mockRes._used) { + return { + [RAW_RESPONSE]: true as const, + status: mockRes._status, + headers: mockRes._headers, + body: mockRes._body + }; + } if (resultHandler) { // Create a minimal response object that captures what apiResultHandler sets. @@ -102,6 +121,42 @@ function createRoute(router: BrowserRouter) { }; } +/** + * Creates a mock Express response object that captures calls to set(), send(), sendStatus(), etc. + * Used for route handlers (like image routes) that write directly to the response. + */ +function createMockExpressResponse() { + const res = { + _used: false, + _status: 200, + _headers: {} as Record, + _body: null as unknown, + set(name: string, value: string) { + res._headers[name] = value; + return res; + }, + setHeader(name: string, value: string) { + res._headers[name] = value; + return res; + }, + status(code: number) { + res._status = code; + return res; + }, + send(body: unknown) { + res._used = true; + res._body = body; + return res; + }, + sendStatus(code: number) { + res._used = true; + res._status = code; + return res; + } + }; + return res; +} + /** * Standalone apiResultHandler matching the server's behavior: * - Converts Becca entities to POJOs @@ -154,7 +209,8 @@ export function registerRoutes(router: BrowserRouter): void { apiRoute, asyncApiRoute: createApiRoute(router, false), apiResultHandler, - checkApiAuth + checkApiAuth, + checkApiAuthOrElectron: checkApiAuth }); apiRoute('get', '/bootstrap', bootstrapRoute); diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 429cac69af..f42ddd3f33 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -25,7 +25,6 @@ import etapiTokensApiRoutes from "./api/etapi_tokens.js"; import exportRoute from "./api/export.js"; import filesRoute from "./api/files.js"; import fontsRoute from "./api/fonts.js"; -import imageRoute from "./api/image.js"; import importRoute from "./api/import.js"; import loginApiRoute from "./api/login.js"; import metricsRoute from "./api/metrics.js"; @@ -87,7 +86,8 @@ function register(app: express.Application) { apiRoute, asyncApiRoute, apiResultHandler, - checkApiAuth: auth.checkApiAuth + checkApiAuth: auth.checkApiAuth, + checkApiAuthOrElectron: auth.checkApiAuthOrElectron }); route(PUT, "/api/notes/:noteId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateFile, apiResultHandler); @@ -110,7 +110,6 @@ function register(app: express.Application) { // TODO: Bring back attachment uploading // route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler); - route(GET, "/api/attachments/:attachmentId/image/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnAttachedImage); route(GET, "/api/attachments/:attachmentId/open", [auth.checkApiAuthOrElectron], filesRoute.openAttachment); asyncRoute( GET, @@ -129,9 +128,10 @@ function register(app: express.Application) { apiRoute(PST, "/api/attachments/:attachmentId/upload-modified-file", filesRoute.uploadModifiedFileToAttachment); route(PUT, "/api/attachments/:attachmentId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateAttachment, apiResultHandler); - route(GET, "/api/revisions/:revisionId/image/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnImageFromRevision); + // TODO: Re-enable once ported to core. + // route(PUT, "/api/images/:noteId", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], imageRoute.updateImage, apiResultHandler); - // TODO: Re-enable once we suppourt route() + // TODO: Re-enable once we support route() // route(GET, "/api/revisions/:revisionId/download", [auth.checkApiAuthOrElectron], revisionsApiRoute.downloadRevision); route(GET, "/api/branches/:branchId/export/:type/:format/:version/:taskId", [auth.checkApiAuthOrElectron], exportRoute.exportBranch); @@ -139,8 +139,6 @@ function register(app: express.Application) { route(PST, "/api/notes/:parentNoteId/attachments-import", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], importRoute.importAttachmentsToNote, apiResultHandler); // :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename - route(GET, "/api/images/:noteId/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnImageFromNote); - route(PUT, "/api/images/:noteId", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], imageRoute.updateImage, apiResultHandler); apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword); apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword); diff --git a/apps/server/src/routes/api/image.spec.ts b/packages/trilium-core/src/routes/api/image.spec.ts similarity index 100% rename from apps/server/src/routes/api/image.spec.ts rename to packages/trilium-core/src/routes/api/image.spec.ts diff --git a/apps/server/src/routes/api/image.ts b/packages/trilium-core/src/routes/api/image.ts similarity index 94% rename from apps/server/src/routes/api/image.ts rename to packages/trilium-core/src/routes/api/image.ts index 33b9df7498..d359ec9ae9 100644 --- a/apps/server/src/routes/api/image.ts +++ b/packages/trilium-core/src/routes/api/image.ts @@ -1,11 +1,9 @@ import type { Request, Response } from "express"; -import fs from "fs"; import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; import type BRevision from "../../becca/entities/brevision.js"; import imageService from "../../services/image.js"; -import { RESOURCE_DIR } from "../../services/resource_dir.js"; function returnImageFromNote(req: Request<{ noteId: string }>, res: Response) { const image = becca.getNote(req.params.noteId); @@ -22,7 +20,8 @@ function returnImageFromRevision(req: Request<{ revisionId: string }>, res: Resp function returnImageInt(image: BNote | BRevision | null, res: Response) { if (!image) { res.set("Content-Type", "image/png"); - return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`)); + // return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`)); + return res.sendStatus(404); } else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) { return res.sendStatus(400); } @@ -79,7 +78,8 @@ function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Respon if (!attachment) { res.set("Content-Type", "image/png"); - return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`)); + // return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`)); + return res.sendStatus(404); } if (!["image"].includes(attachment.role)) { diff --git a/packages/trilium-core/src/routes/index.ts b/packages/trilium-core/src/routes/index.ts index 2a30ca0574..e0e3c9856e 100644 --- a/packages/trilium-core/src/routes/index.ts +++ b/packages/trilium-core/src/routes/index.ts @@ -22,6 +22,7 @@ import specialNotesRoute from "./api/special_notes"; import syncApiRoute from "./api/sync"; import autocompleteApiRoute from "./api/autocomplete"; import similarNotesRoute from "./api/similar_notes"; +import imageRoute from "./api/image"; // TODO: Deduplicate with routes.ts const GET = "get", @@ -36,9 +37,10 @@ interface SharedApiRoutesContext { asyncApiRoute: any; checkApiAuth: any; apiResultHandler: any; + checkApiAuthOrElectron: any; } -export function buildSharedApiRoutes({ route, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler }: SharedApiRoutesContext) { +export function buildSharedApiRoutes({ route, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler, checkApiAuthOrElectron }: SharedApiRoutesContext) { apiRoute(GET, '/api/tree', treeApiRoute.getTree); apiRoute(PST, '/api/tree/load', treeApiRoute.load); @@ -105,6 +107,10 @@ export function buildSharedApiRoutes({ route, apiRoute, asyncApiRoute, checkApiA apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix); apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch); + route(GET, "/api/revisions/:revisionId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromRevision); + route(GET, "/api/attachments/:attachmentId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnAttachedImage); + route(GET, "/api/images/:noteId/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromNote); + asyncApiRoute(PST, "/api/sync/test", syncApiRoute.testSync); asyncApiRoute(PST, "/api/sync/now", syncApiRoute.syncNow); apiRoute(PST, "/api/sync/fill-entity-changes", syncApiRoute.fillEntityChanges);