mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-08-18 16:32:33 +08:00
refactor(server): separate routes from route API
This commit is contained in:
parent
acc83ae1c2
commit
6f3339211c
183
apps/server/src/routes/route_api.ts
Normal file
183
apps/server/src/routes/route_api.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import express from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import log from "../services/log.js";
|
||||||
|
import cls from "../services/cls.js";
|
||||||
|
import sql from "../services/sql.js";
|
||||||
|
import entityChangesService from "../services/entity_changes.js";
|
||||||
|
import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||||
|
import NotFoundError from "../errors/not_found_error.js";
|
||||||
|
import ValidationError from "../errors/validation_error.js";
|
||||||
|
import auth from "../services/auth.js";
|
||||||
|
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||||
|
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||||
|
|
||||||
|
const MAX_ALLOWED_FILE_SIZE_MB = 250;
|
||||||
|
export const router = express.Router();
|
||||||
|
|
||||||
|
// TODO: Deduplicate with etapi_utils.ts afterwards.
|
||||||
|
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
|
||||||
|
|
||||||
|
export type ApiResultHandler = (req: express.Request, res: express.Response, result: unknown) => number;
|
||||||
|
export type ApiRequestHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => unknown;
|
||||||
|
|
||||||
|
/** Handling common patterns. If entity is not caught, serialization to JSON will fail */
|
||||||
|
function convertEntitiesToPojo(result: unknown) {
|
||||||
|
if (result instanceof AbstractBeccaEntity) {
|
||||||
|
result = result.getPojo();
|
||||||
|
} else if (Array.isArray(result)) {
|
||||||
|
for (const idx in result) {
|
||||||
|
if (result[idx] instanceof AbstractBeccaEntity) {
|
||||||
|
result[idx] = result[idx].getPojo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (result && typeof result === "object") {
|
||||||
|
if ("note" in result && result.note instanceof AbstractBeccaEntity) {
|
||||||
|
result.note = result.note.getPojo();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("branch" in result && result.branch instanceof AbstractBeccaEntity) {
|
||||||
|
result.branch = result.branch.getPojo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && typeof result === "object" && "executionResult" in result) {
|
||||||
|
// from runOnBackend()
|
||||||
|
result.executionResult = convertEntitiesToPojo(result.executionResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiResultHandler(req: express.Request, res: express.Response, result: unknown) {
|
||||||
|
res.setHeader("trilium-max-entity-change-id", entityChangesService.getMaxEntityChangeId());
|
||||||
|
|
||||||
|
result = convertEntitiesToPojo(result);
|
||||||
|
|
||||||
|
// if it's an array and the first element is integer, then we consider this to be [statusCode, response] format
|
||||||
|
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||||
|
const [statusCode, response] = result;
|
||||||
|
|
||||||
|
if (statusCode !== 200 && statusCode !== 201 && statusCode !== 204) {
|
||||||
|
log.info(`${req.method} ${req.originalUrl} returned ${statusCode} with response ${JSON.stringify(response)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return send(res, statusCode, response);
|
||||||
|
} else if (result === undefined) {
|
||||||
|
return send(res, 204, "");
|
||||||
|
} else {
|
||||||
|
return send(res, 200, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res: express.Response, statusCode: number, response: unknown) {
|
||||||
|
if (typeof response === "string") {
|
||||||
|
if (statusCode >= 400) {
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).send(response);
|
||||||
|
|
||||||
|
return response.length;
|
||||||
|
} else {
|
||||||
|
const json = JSON.stringify(response);
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.status(statusCode).send(json);
|
||||||
|
|
||||||
|
return json.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiRoute(method: HttpMethod, path: string, routeHandler: ApiRequestHandler) {
|
||||||
|
route(method, path, [auth.checkApiAuth, csrfMiddleware], routeHandler, apiResultHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function route(method: HttpMethod, path: string, middleware: express.Handler[], routeHandler: ApiRequestHandler, resultHandler: ApiResultHandler | null = null, transactional = true) {
|
||||||
|
router[method](path, ...(middleware as express.Handler[]), (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
cls.namespace.bindEmitter(req);
|
||||||
|
cls.namespace.bindEmitter(res);
|
||||||
|
|
||||||
|
const result = cls.init(() => {
|
||||||
|
cls.set("componentId", req.headers["trilium-component-id"]);
|
||||||
|
cls.set("localNowDateTime", req.headers["trilium-local-now-datetime"]);
|
||||||
|
cls.set("hoistedNoteId", req.headers["trilium-hoisted-note-id"] || "root");
|
||||||
|
|
||||||
|
const cb = () => routeHandler(req, res, next);
|
||||||
|
|
||||||
|
return transactional ? sql.transactional(cb) : cb();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resultHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.then) {
|
||||||
|
// promise
|
||||||
|
result.then((promiseResult: unknown) => handleResponse(resultHandler, req, res, promiseResult, start)).catch((e: unknown) => handleException(e, method, path, res));
|
||||||
|
} else {
|
||||||
|
handleResponse(resultHandler, req, res, result, start);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
handleException(e, method, path, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResponse(resultHandler: ApiResultHandler, req: express.Request, res: express.Response, result: unknown, start: number) {
|
||||||
|
// Skip result handling if the response has already been handled
|
||||||
|
if ((res as any).triliumResponseHandled) {
|
||||||
|
// Just log the request without additional processing
|
||||||
|
log.request(req, res, Date.now() - start, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseLength = resultHandler(req, res, result);
|
||||||
|
log.request(req, res, Date.now() - start, responseLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleException(e: unknown | Error, method: HttpMethod, path: string, res: express.Response) {
|
||||||
|
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
||||||
|
|
||||||
|
log.error(`${method} ${path} threw exception: '${errMessage}', stack: ${errStack}`);
|
||||||
|
|
||||||
|
const resStatusCode = (e instanceof ValidationError || e instanceof NotFoundError) ? e.statusCode : 500;
|
||||||
|
|
||||||
|
res.status(resStatusCode).json({
|
||||||
|
message: errMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUploadMiddleware() {
|
||||||
|
const multerOptions: multer.Options = {
|
||||||
|
fileFilter: (req: express.Request, file, cb) => {
|
||||||
|
// UTF-8 file names are not well decoded by multer/busboy, so we handle the conversion on our side.
|
||||||
|
// See https://github.com/expressjs/multer/pull/1102.
|
||||||
|
file.originalname = Buffer.from(file.originalname, "latin1").toString("utf-8");
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
|
||||||
|
multerOptions.limits = {
|
||||||
|
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return multer(multerOptions).single("upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadMiddleware = createUploadMiddleware();
|
||||||
|
|
||||||
|
export const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
|
uploadMiddleware(req, res, function (err) {
|
||||||
|
if (err?.code === "LIMIT_FILE_SIZE") {
|
||||||
|
res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -1,21 +1,13 @@
|
|||||||
import { isElectron, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
import { isElectron } from "../services/utils.js";
|
||||||
import multer from "multer";
|
|
||||||
import log from "../services/log.js";
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
const router = express.Router();
|
|
||||||
import auth from "../services/auth.js";
|
import auth from "../services/auth.js";
|
||||||
import openID from '../services/open_id.js';
|
import openID from '../services/open_id.js';
|
||||||
import totp from './api/totp.js';
|
import totp from './api/totp.js';
|
||||||
import recoveryCodes from './api/recovery_codes.js';
|
import recoveryCodes from './api/recovery_codes.js';
|
||||||
import cls from "../services/cls.js";
|
|
||||||
import sql from "../services/sql.js";
|
|
||||||
import entityChangesService from "../services/entity_changes.js";
|
|
||||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||||
import { createPartialContentHandler } from "@triliumnext/express-partial-content";
|
import { createPartialContentHandler } from "@triliumnext/express-partial-content";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
|
||||||
import NotFoundError from "../errors/not_found_error.js";
|
|
||||||
import ValidationError from "../errors/validation_error.js";
|
|
||||||
|
|
||||||
// page routes
|
// page routes
|
||||||
import setupRoute from "./setup.js";
|
import setupRoute from "./setup.js";
|
||||||
@ -77,33 +69,14 @@ import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
|
|||||||
import etapiSpecRoute from "../etapi/spec.js";
|
import etapiSpecRoute from "../etapi/spec.js";
|
||||||
import etapiBackupRoute from "../etapi/backup.js";
|
import etapiBackupRoute from "../etapi/backup.js";
|
||||||
import apiDocsRoute from "./api_docs.js";
|
import apiDocsRoute from "./api_docs.js";
|
||||||
|
import { apiResultHandler, apiRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
|
||||||
|
|
||||||
|
|
||||||
const MAX_ALLOWED_FILE_SIZE_MB = 250;
|
|
||||||
const GET = "get",
|
const GET = "get",
|
||||||
PST = "post",
|
PST = "post",
|
||||||
PUT = "put",
|
PUT = "put",
|
||||||
PATCH = "patch",
|
PATCH = "patch",
|
||||||
DEL = "delete";
|
DEL = "delete";
|
||||||
|
|
||||||
export type ApiResultHandler = (req: express.Request, res: express.Response, result: unknown) => number;
|
|
||||||
export type ApiRequestHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => unknown;
|
|
||||||
|
|
||||||
// TODO: Deduplicate with etapi_utils.ts afterwards.
|
|
||||||
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
|
|
||||||
|
|
||||||
const uploadMiddleware = createUploadMiddleware();
|
|
||||||
|
|
||||||
const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) {
|
|
||||||
uploadMiddleware(req, res, function (err) {
|
|
||||||
if (err?.code === "LIMIT_FILE_SIZE") {
|
|
||||||
res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
|
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function register(app: express.Application) {
|
function register(app: express.Application) {
|
||||||
route(GET, "/", [auth.checkAuth, csrfMiddleware], indexRoute.index);
|
route(GET, "/", [auth.checkAuth, csrfMiddleware], indexRoute.index);
|
||||||
route(GET, "/login", [auth.checkAppInitialized, auth.checkPasswordSet], loginRoute.loginPage);
|
route(GET, "/login", [auth.checkAppInitialized, auth.checkPasswordSet], loginRoute.loginPage);
|
||||||
@ -436,156 +409,6 @@ function register(app: express.Application) {
|
|||||||
app.use("", router);
|
app.use("", router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handling common patterns. If entity is not caught, serialization to JSON will fail */
|
|
||||||
function convertEntitiesToPojo(result: unknown) {
|
|
||||||
if (result instanceof AbstractBeccaEntity) {
|
|
||||||
result = result.getPojo();
|
|
||||||
} else if (Array.isArray(result)) {
|
|
||||||
for (const idx in result) {
|
|
||||||
if (result[idx] instanceof AbstractBeccaEntity) {
|
|
||||||
result[idx] = result[idx].getPojo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (result && typeof result === "object") {
|
|
||||||
if ("note" in result && result.note instanceof AbstractBeccaEntity) {
|
|
||||||
result.note = result.note.getPojo();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("branch" in result && result.branch instanceof AbstractBeccaEntity) {
|
|
||||||
result.branch = result.branch.getPojo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result && typeof result === "object" && "executionResult" in result) {
|
|
||||||
// from runOnBackend()
|
|
||||||
result.executionResult = convertEntitiesToPojo(result.executionResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiResultHandler(req: express.Request, res: express.Response, result: unknown) {
|
|
||||||
res.setHeader("trilium-max-entity-change-id", entityChangesService.getMaxEntityChangeId());
|
|
||||||
|
|
||||||
result = convertEntitiesToPojo(result);
|
|
||||||
|
|
||||||
// if it's an array and the first element is integer, then we consider this to be [statusCode, response] format
|
|
||||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
|
||||||
const [statusCode, response] = result;
|
|
||||||
|
|
||||||
if (statusCode !== 200 && statusCode !== 201 && statusCode !== 204) {
|
|
||||||
log.info(`${req.method} ${req.originalUrl} returned ${statusCode} with response ${JSON.stringify(response)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return send(res, statusCode, response);
|
|
||||||
} else if (result === undefined) {
|
|
||||||
return send(res, 204, "");
|
|
||||||
} else {
|
|
||||||
return send(res, 200, result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(res: express.Response, statusCode: number, response: unknown) {
|
|
||||||
if (typeof response === "string") {
|
|
||||||
if (statusCode >= 400) {
|
|
||||||
res.setHeader("Content-Type", "text/plain");
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(statusCode).send(response);
|
|
||||||
|
|
||||||
return response.length;
|
|
||||||
} else {
|
|
||||||
const json = JSON.stringify(response);
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.status(statusCode).send(json);
|
|
||||||
|
|
||||||
return json.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiRoute(method: HttpMethod, path: string, routeHandler: ApiRequestHandler) {
|
|
||||||
route(method, path, [auth.checkApiAuth, csrfMiddleware], routeHandler, apiResultHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function route(method: HttpMethod, path: string, middleware: express.Handler[], routeHandler: ApiRequestHandler, resultHandler: ApiResultHandler | null = null, transactional = true) {
|
|
||||||
router[method](path, ...(middleware as express.Handler[]), (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
cls.namespace.bindEmitter(req);
|
|
||||||
cls.namespace.bindEmitter(res);
|
|
||||||
|
|
||||||
const result = cls.init(() => {
|
|
||||||
cls.set("componentId", req.headers["trilium-component-id"]);
|
|
||||||
cls.set("localNowDateTime", req.headers["trilium-local-now-datetime"]);
|
|
||||||
cls.set("hoistedNoteId", req.headers["trilium-hoisted-note-id"] || "root");
|
|
||||||
|
|
||||||
const cb = () => routeHandler(req, res, next);
|
|
||||||
|
|
||||||
return transactional ? sql.transactional(cb) : cb();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resultHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result?.then) {
|
|
||||||
// promise
|
|
||||||
result.then((promiseResult: unknown) => handleResponse(resultHandler, req, res, promiseResult, start)).catch((e: unknown) => handleException(e, method, path, res));
|
|
||||||
} else {
|
|
||||||
handleResponse(resultHandler, req, res, result, start);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
handleException(e, method, path, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResponse(resultHandler: ApiResultHandler, req: express.Request, res: express.Response, result: unknown, start: number) {
|
|
||||||
// Skip result handling if the response has already been handled
|
|
||||||
if ((res as any).triliumResponseHandled) {
|
|
||||||
// Just log the request without additional processing
|
|
||||||
log.request(req, res, Date.now() - start, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseLength = resultHandler(req, res, result);
|
|
||||||
log.request(req, res, Date.now() - start, responseLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleException(e: unknown | Error, method: HttpMethod, path: string, res: express.Response) {
|
|
||||||
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
|
||||||
|
|
||||||
log.error(`${method} ${path} threw exception: '${errMessage}', stack: ${errStack}`);
|
|
||||||
|
|
||||||
const resStatusCode = (e instanceof ValidationError || e instanceof NotFoundError) ? e.statusCode : 500;
|
|
||||||
|
|
||||||
res.status(resStatusCode).json({
|
|
||||||
message: errMessage
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUploadMiddleware() {
|
|
||||||
const multerOptions: multer.Options = {
|
|
||||||
fileFilter: (req: express.Request, file, cb) => {
|
|
||||||
// UTF-8 file names are not well decoded by multer/busboy, so we handle the conversion on our side.
|
|
||||||
// See https://github.com/expressjs/multer/pull/1102.
|
|
||||||
file.originalname = Buffer.from(file.originalname, "latin1").toString("utf-8");
|
|
||||||
cb(null, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
|
|
||||||
multerOptions.limits = {
|
|
||||||
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return multer(multerOptions).single("upload");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
register
|
register
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user