Files
Termix/src/backend/database/routes/plugin-api-routes.ts
T

135 lines
4.7 KiB
TypeScript

// Mounted at /plugin-api. Every plugin that serves HTTP registers a router
// through ctx.http.router(), and this dispatcher forwards requests to it by
// plugin id. The routers themselves live in src/backend/plugins/http.ts, which
// also owns the middleware stack in front of them: auth, the actor, the
// enabled check, body limits and the error wrapper.
//
// The dispatcher stays deliberately thin. It resolves a plugin id to a router
// and nothing else, so there is no place here for a check keyed on a
// particular plugin.
//
// WebSocket note: plugin sockets do NOT ride this router, and they do not get
// their own port either. They are served at /plugin-ws/<id>/<path> from the
// main server's upgrade event by src/backend/plugins/ws.ts. (An earlier
// comment here described a named-channel envelope multiplexed over the
// existing WS servers; A4 replaced that with the /plugin-ws prefix.)
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { getPluginRouter } from "../../plugins/http.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
const router = express.Router();
/**
* @openapi
* /plugin-api/{pluginId}/{path}:
* get:
* summary: Dispatch a request to an installed plugin's backend router
* description: >
* Forwards the request to the router the plugin with the given id
* registered through ctx.http.router(). Returns 404 if the plugin is not
* installed or serves no routes, and 503 if it is installed but not
* currently running. All HTTP methods are dispatched the same way.
* tags:
* - Plugins
* parameters:
* - in: path
* name: pluginId
* required: true
* schema:
* type: string
* - in: path
* name: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Response from the plugin's router.
* 401:
* description: Authentication required.
* 404:
* description: Plugin not installed or serves no routes.
* 503:
* description: Plugin is installed but not running.
*/
router.use("/:pluginId", (req: Request, res: Response, next) => {
const pluginId = String(req.params.pluginId);
const pluginRouter = getPluginRouter(pluginId);
if (!pluginRouter) {
databaseLogger.warn("Plugin API request for unregistered plugin", {
operation: "plugin_api_dispatch",
pluginId,
});
res.status(404).json({ error: "Plugin not installed or not running" });
return;
}
pluginRouter(req, res, next);
});
/**
* Mounts the dispatcher. No auth in front of it: each plugin router runs
* core auth itself and skips it only for the paths it declared public, so a
* webhook or OIDC callback a plugin serves can actually be reached.
*/
export function mountPluginApi(app: express.Express): void {
app.use("/plugin-api", router);
}
export interface ActiveLegacyRoutes {
id: string;
legacyPaths: string[];
legacyRedirects: PluginLegacyRedirect[];
}
function under(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`);
}
/**
* Serves the old URLs plugins declare in contributes.http. A legacyPaths
* entry, under "/<plugin id>/", runs through that plugin's router with the
* prefix removed. A legacyRedirects entry redirects to the plugin's own URL,
* keeping the rest of the path and the query. Mounted after every core route,
* so core wins a clash.
*/
export function mountPluginLegacyPaths(
app: express.Express,
listActive: () => ActiveLegacyRoutes[],
): void {
app.use((req: Request, res: Response, next) => {
for (const plugin of listActive()) {
const redirect = plugin.legacyRedirects.find((entry) =>
under(req.path, entry.from),
);
if (redirect) {
const rest = req.path.slice(redirect.from.length);
const query = req.originalUrl.indexOf("?");
res.redirect(
redirect.status ?? 307,
`${getRequestBasePath(req)}/plugin-api/${plugin.id}${redirect.to}${rest}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`,
);
return;
}
if (!plugin.legacyPaths.some((path) => under(req.path, path))) continue;
const pluginRouter = getPluginRouter(plugin.id);
if (!pluginRouter) {
res.status(404).json({ error: "Plugin not installed or not running" });
return;
}
req.url = req.url.slice(plugin.id.length + 1) || "/";
pluginRouter(req, res, next);
return;
}
next();
});
}
export default router;