mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -0,0 +1,218 @@
|
||||
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
|
||||
//
|
||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||
// bearer header, and nothing here is secret. It only ever serves files from
|
||||
// the two directories a plugin ships for the browser, so a request can never
|
||||
// reach the plugin's backend bundle, migrations or anything outside it.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import {
|
||||
DEFAULT_FRONTEND_ENTRY,
|
||||
DEFAULT_LOCALES_DIR,
|
||||
type PluginManifest,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
import type { LoadedPlugin } from "./loader.js";
|
||||
|
||||
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest">;
|
||||
|
||||
export interface PluginFrontendInfo {
|
||||
frontend: boolean;
|
||||
css: boolean;
|
||||
/** Changes whenever the built bundle does, for cache busting. */
|
||||
assetVersion: string | null;
|
||||
/** Language codes with a locale file, "en" plus xx_YY names. */
|
||||
locales: string[];
|
||||
}
|
||||
|
||||
function frontendEntry(
|
||||
plugin: AssetPlugin,
|
||||
): { dir: string; file: string } | null {
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
const entry = path.resolve(
|
||||
plugin.dir,
|
||||
manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
|
||||
);
|
||||
if (!isInside(plugin.dir, entry)) return null;
|
||||
return { dir: path.dirname(entry), file: entry };
|
||||
}
|
||||
|
||||
function localesDir(plugin: AssetPlugin): string | null {
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
const dir = path.resolve(plugin.dir, manifest.locales ?? DEFAULT_LOCALES_DIR);
|
||||
return isInside(plugin.dir, dir) ? dir : null;
|
||||
}
|
||||
|
||||
function isInside(root: string, target: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(target));
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function fileExists(file: string): boolean {
|
||||
try {
|
||||
return fs.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function describePluginFrontend(
|
||||
plugin: AssetPlugin | undefined,
|
||||
): PluginFrontendInfo {
|
||||
const none: PluginFrontendInfo = {
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
};
|
||||
if (!plugin) return none;
|
||||
|
||||
const locales: string[] = [];
|
||||
const dir = localesDir(plugin);
|
||||
if (dir && fileExists(path.join(dir, "en.json"))) locales.push("en");
|
||||
if (dir) {
|
||||
try {
|
||||
for (const name of fs.readdirSync(path.join(dir, "translated"))) {
|
||||
if (/^[a-z]{2}_[A-Z]{2}\.json$/.test(name)) {
|
||||
locales.push(name.slice(0, -".json".length));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No translations yet.
|
||||
}
|
||||
}
|
||||
|
||||
const entry = frontendEntry(plugin);
|
||||
if (!entry || !fileExists(entry.file)) return { ...none, locales };
|
||||
|
||||
const stat = fs.statSync(entry.file);
|
||||
const assetVersion = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
|
||||
return {
|
||||
frontend: true,
|
||||
css: fileExists(path.join(entry.dir, "frontend.css")),
|
||||
assetVersion,
|
||||
locales: locales.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
|
||||
*
|
||||
* `locales/...` reads the plugin's locales directory; anything else reads the
|
||||
* directory the frontend bundle sits in. Only .js, .css, .json and .map are
|
||||
* served, and never a path that escapes either directory.
|
||||
*/
|
||||
export function resolvePluginAsset(
|
||||
plugin: AssetPlugin,
|
||||
requestPath: string,
|
||||
): string | null {
|
||||
const clean = requestPath.replace(/^\/+/, "");
|
||||
if (!clean || clean.includes("\0") || clean.includes("\\")) return null;
|
||||
if (!/\.(js|css|json|map)$/.test(clean)) return null;
|
||||
|
||||
let root: string | null;
|
||||
let rest: string;
|
||||
if (clean.startsWith("locales/")) {
|
||||
root = localesDir(plugin);
|
||||
rest = clean.slice("locales/".length);
|
||||
} else {
|
||||
root = frontendEntry(plugin)?.dir ?? null;
|
||||
rest = clean;
|
||||
// The backend bundle sits beside the frontend one in dist/.
|
||||
if (/^backend\.js(\.map)?$/.test(rest)) return null;
|
||||
}
|
||||
if (!root) return null;
|
||||
|
||||
const file = path.resolve(root, rest);
|
||||
if (!isInside(root, file) || !fileExists(file)) return null;
|
||||
return file;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
export function createPluginAssetsRouter(
|
||||
getPlugin: (id: string) => AssetPlugin | undefined,
|
||||
): express.Router {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-assets/{pluginId}/{path}:
|
||||
* get:
|
||||
* summary: Serve a plugin's frontend bundle, stylesheet or locale file
|
||||
* description: >
|
||||
* Public, like the app's own /assets, because a module import cannot
|
||||
* send an auth header and plugin frontends are not secret. Serves only
|
||||
* the directory holding the plugin's frontend bundle and its locales
|
||||
* directory. A `v` query parameter (the assetVersion from GET
|
||||
* /plugins) makes the response cacheable for a year.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: pluginId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: v
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The file.
|
||||
* 404:
|
||||
* description: Unknown plugin or file.
|
||||
*/
|
||||
router.get(
|
||||
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
|
||||
(req: Request, res: Response) => {
|
||||
const params = req.params as unknown as Record<string, string>;
|
||||
const pluginId = params[0];
|
||||
const plugin = getPlugin(pluginId);
|
||||
const file = plugin ? resolvePluginAsset(plugin, params[1]) : null;
|
||||
|
||||
// Loaded as a module from Electron's file:// page and the Vite dev
|
||||
// server, so any origin may read it. No credentials are involved.
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.removeHeader("Access-Control-Allow-Credentials");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
if (!file) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
typeof req.query.v === "string" && req.query.v
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
);
|
||||
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
|
||||
res.sendFile(file);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in new issue
Block a user