mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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>
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user