* 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>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

@@ -1,5 +1,4 @@
import sharp from "sharp";
import { databaseLogger } from "../../utils/logger.js";
import { consumeByName } from "../../plugins/registry.js";
/**
* White-label branding: admin-configurable app name, logo and tagline shown
@@ -20,25 +19,33 @@ export const BRANDING_KEYS = {
} as const;
export const BRANDING_DEFAULT_APP_NAME = "Termix";
const APP_NAME_MAX_LENGTH = 60;
const TAGLINE_MAX_LENGTH = 160;
/**
* Cap applies to the decoded image bytes, not the base64 text. The settings
* table is fully cached in memory per backend process (see
* settings-cache.ts), so an oversized logo would be permanently resident RAM
* on every instance, not just a one-time upload cost.
*/
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
const BRANDING_LOGO_MAX_INPUT_PIXELS = 20_000_000;
interface SettingSource {
get(key: string): Promise<string | null>;
}
export const BRANDING_PROVIDER_NAME = "brandingProvider";
/** A plugin supplies branding by providing "<its id>.brandingProvider". */
export type BrandingProvider = () =>
Promise<BrandingSettings> | BrandingSettings;
export async function resolveBrandingSettings(
settings: SettingSource,
): Promise<BrandingSettings> {
const provider = consumeByName<BrandingProvider>(BRANDING_PROVIDER_NAME);
if (provider) {
try {
const provided = await provider();
return {
appName: provided.appName?.trim() || BRANDING_DEFAULT_APP_NAME,
tagline: provided.tagline ?? "",
logo: provided.logo || null,
};
} catch {
// A broken provider falls back to the stored settings.
}
}
const [appName, tagline, logo] = await Promise.all([
settings.get(BRANDING_KEYS.appName),
settings.get(BRANDING_KEYS.tagline),
@@ -53,74 +60,3 @@ export async function resolveBrandingSettings(
}
/** Trimmed, 1-60 chars. Falls back to the default when cleared. */
export function parseBrandingAppName(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length === 0) return BRANDING_DEFAULT_APP_NAME;
if (trimmed.length > APP_NAME_MAX_LENGTH) return null;
return trimmed;
}
/** Trimmed, 0-160 chars. An empty string means "use the built-in default". */
export function parseBrandingTagline(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length > TAGLINE_MAX_LENGTH) return null;
return trimmed;
}
/**
* Validates and re-encodes an uploaded logo. Only raster formats are
* accepted (no SVG), sidestepping SVG-script XSS entirely rather than trying
* to sanitize markup. The image is always re-encoded to PNG through sharp:
* this both proves the payload is a genuine image (not a disguised file
* behind a spoofed data-URL prefix) and strips any embedded metadata.
*
* Returns null on any validation failure; the caller is expected to reject
* the request with a 400 without echoing back details of what was rejected.
*/
export async function parseBrandingLogoDataUrl(
value: unknown,
): Promise<string | null> {
if (typeof value !== "string") return null;
const match = value.match(
/^data:image\/(png|jpe?g|webp);base64,([a-zA-Z0-9+/]+=*)$/,
);
if (!match) return null;
const [, , base64] = match;
let buffer: Buffer;
try {
buffer = Buffer.from(base64, "base64");
} catch {
return null;
}
if (buffer.length === 0 || buffer.length > BRANDING_LOGO_MAX_BYTES * 4) {
// Reject wildly oversized input before decoding it with sharp; the final
// size check happens below on the re-encoded PNG.
return null;
}
try {
const source = sharp(buffer, {
failOn: "error",
limitInputPixels: BRANDING_LOGO_MAX_INPUT_PIXELS,
});
const metadata = await source.metadata();
if (
!metadata.format ||
!["png", "jpeg", "webp"].includes(metadata.format)
) {
return null;
}
const normalized = await source.rotate().png().toBuffer();
if (normalized.length > BRANDING_LOGO_MAX_BYTES) return null;
return `data:image/png;base64,${normalized.toString("base64")}`;
} catch (err) {
databaseLogger.warn("Rejected invalid branding logo upload", {
operation: "branding_logo_invalid",
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}