Files
Termix/src/backend/plugins/assets.ts
T
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00

330 lines
11 KiB
TypeScript

// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
// CSS files its themes declare.
//
// 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, and
// only for a plugin that is running.
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_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
type PluginManifest,
} from "@termix-ssh/plugin-sdk/manifest";
import type { LoadedPlugin } from "./loader.js";
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest"> &
Partial<Pick<LoadedPlugin, "state">>;
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[];
/** Changes whenever a theme's CSS does, for its ?v= link. */
themeVersion: string | null;
}
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
export const THEME_ASSET_PREFIX = "_themes/";
/** The declared CSS file of each of the plugin's themes, by theme id. */
function themeFiles(plugin: AssetPlugin): Map<string, string> {
const files = new Map<string, string>();
const manifest = plugin.manifest as PluginManifest;
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.resolve(plugin.dir, theme.css);
if (isInside(plugin.dir, file)) files.set(theme.id, file);
}
return files;
}
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: [],
themeVersion: null,
};
if (!plugin) return none;
const themes = [...themeFiles(plugin).values()].filter(fileExists);
const themeVersion =
themes.length > 0
? contentVersion(themes.sort(), plugin.manifest.version)
: null;
const locales: string[] = [];
const localeFiles: string[] = [];
const dir = localesDir(plugin);
if (dir && fileExists(path.join(dir, "en.json"))) {
locales.push("en");
localeFiles.push(path.join(dir, "en.json"));
}
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));
localeFiles.push(path.join(dir, "translated", name));
}
}
} catch {
// No translations yet.
}
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file))
return { ...none, locales, themeVersion };
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
// Locales share the version, so a translations-only update must change it.
const assetVersion = contentVersion(
[...(css ? [entry.file, cssFile] : [entry.file]), ...localeFiles.sort()],
plugin.manifest.version,
);
return {
frontend: true,
css,
assetVersion,
locales: locales.sort(),
themeVersion,
};
}
const versionCache = new Map<string, { stamp: string; version: string }>();
/**
* A hash of the bundle's bytes, not its mtime: assets are cached as immutable
* for a year, and a rebuilt .tmxplug has a fixed mtime, so a same-size rebuild
* would otherwise keep serving the stale copy. The CSS and locales are hashed
* with the JS because all of them are served under the same version.
*/
function contentVersion(files: string[], version: string): string {
// ctime, unlike mtime, cannot be set by tar, so an unpack always changes it.
const stamp = [
version,
...files.map((file) => {
const stat = fs.statSync(file);
return `${file}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
}),
].join("|");
const key = files.join("|");
const cached = versionCache.get(key);
if (cached?.stamp === stamp) return cached.version;
const hash = crypto.createHash("sha256").update(version);
for (const file of files) hash.update(fs.readFileSync(file));
const hashed = hash.digest("hex").slice(0, 12);
versionCache.set(key, { stamp, version: hashed });
return hashed;
}
/**
* 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(THEME_ASSET_PREFIX)) {
// Only a declared theme file, which the loader checked on load.
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
const file = themeFiles(plugin).get(themeId);
if (!file || !clean.endsWith(".css")) return null;
root = path.dirname(file);
rest = path.basename(file);
} else 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;
// The text check above cannot see a symlink, so the real paths have to
// agree too: a link inside dist/ must not reach a file outside the plugin.
let real: string;
try {
real = fs.realpathSync(file);
if (!isInside(fs.realpathSync(plugin.dir), real)) return null;
} catch {
return null;
}
if (isServerOnly(plugin, real)) return null;
return file;
}
/** The backend entry and the migrations, wherever the manifest put them. */
function isServerOnly(plugin: AssetPlugin, real: string): boolean {
const manifest = plugin.manifest as PluginManifest;
const backend = path.resolve(
plugin.dir,
manifest.backend ?? DEFAULT_BACKEND_ENTRY,
);
const candidates = [backend, `${backend}.map`];
for (const candidate of candidates) {
try {
if (fs.realpathSync(candidate) === real) return true;
} catch {
// Not there.
}
}
const segments = path
.relative(fs.realpathSync(plugin.dir), real)
.split(path.sep);
return segments.includes("migrations");
}
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 or stopped plugin, or no such 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);
// A disabled or failed plugin serves nothing, so its bundle cannot be
// loaded around the shell's own enabled check.
const running = !!plugin && (!plugin.state || plugin.state === "active");
const file =
plugin && running ? 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");
// Installed plugins unpack under .unpacked/, which send would refuse as a
// dotfile path. resolvePluginAsset already kept the file in the plugin.
res.sendFile(file, { dotfiles: "allow" }, (error) => {
if (error && !res.headersSent) {
res.status(404).json({ error: "Not found" });
}
});
},
);
return router;
}