feat(core): port image route

This commit is contained in:
Elian Doran
2026-03-23 17:04:17 +02:00
parent 5ea014cc37
commit a259b65085
6 changed files with 102 additions and 15 deletions

View File

@@ -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<string, string>; 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;

View File

@@ -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<string, string>,
_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);

View File

@@ -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);

View File

@@ -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)) {

View File

@@ -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);