mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
300 lines
8.8 KiB
TypeScript
300 lines
8.8 KiB
TypeScript
/**
|
|
* Moves the terminal's settings out of core and into the ssh-terminal
|
|
* plugin's own settings.
|
|
*
|
|
* Admin keys from the core `settings` table: session persistence, command
|
|
* history, the terminal half of the admin host defaults (a new host's font,
|
|
* theme, cursor and auto tmux), touch input tuning and terminal image
|
|
* storage. The image storage
|
|
* local directory was never sent to a browser, so it lands as a secret field,
|
|
* which keeps it that way. The legacy rows are left in place for a release;
|
|
* nothing reads them any more.
|
|
*
|
|
* Host columns from ssh_data: enable_terminal, enable_terminal_toolbar and
|
|
* enable_command_history. All three default to on in the plugin, so only
|
|
* hosts that turned one off need a row.
|
|
*
|
|
* Idempotent: an admin key already set in the plugin, or a host that already
|
|
* has an enableTerminal row, is skipped, so this is safe on every boot.
|
|
*/
|
|
|
|
import { sql } from "drizzle-orm";
|
|
import { databaseLogger } from "../utils/logger.js";
|
|
import {
|
|
legacyFlag,
|
|
selectLegacyRows,
|
|
} from "../utils/crypto-migration/raw-rows.js";
|
|
import {
|
|
createCurrentPluginRepository,
|
|
createCurrentPluginSettingsRepository,
|
|
createCurrentSettingsRepository,
|
|
} from "../database/repositories/factory.js";
|
|
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
|
|
|
|
const PLUGIN_ID = "ssh-terminal";
|
|
|
|
type Parse = (raw: string) => unknown;
|
|
|
|
const asNumber: Parse = (raw) => {
|
|
const value = Number(raw);
|
|
return Number.isFinite(value) ? value : undefined;
|
|
};
|
|
const asBoolean: Parse = (raw) =>
|
|
raw === "true" || raw === "1"
|
|
? true
|
|
: raw === "false" || raw === "0"
|
|
? false
|
|
: undefined;
|
|
const asString: Parse = (raw) => raw;
|
|
const asJson: Parse = (raw) => {
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const CURSOR_STYLES = ["block", "underline", "bar"];
|
|
/** 2.8 spellings of the theme that follows the app. */
|
|
const LEGACY_TERMIX_THEMES = [
|
|
"Termix Dark",
|
|
"Termix Light",
|
|
"termixDark",
|
|
"termixLight",
|
|
];
|
|
|
|
/** One terminal key of the admin host defaults, or undefined when unusable. */
|
|
function newHostDefault(key: string, defaults: unknown): unknown {
|
|
if (!defaults || typeof defaults !== "object") return undefined;
|
|
const value = (defaults as Record<string, unknown>)[key];
|
|
switch (key) {
|
|
case "fontSize": {
|
|
const size = Number(value);
|
|
return Number.isFinite(size) && size >= 8 && size <= 36
|
|
? size
|
|
: undefined;
|
|
}
|
|
case "fontFamily":
|
|
return typeof value === "string" && value ? value : undefined;
|
|
case "theme":
|
|
if (typeof value !== "string" || !value) return undefined;
|
|
return LEGACY_TERMIX_THEMES.includes(value) ? "termix" : value;
|
|
case "cursorStyle":
|
|
return typeof value === "string" && CURSOR_STYLES.includes(value)
|
|
? value
|
|
: undefined;
|
|
default:
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
}
|
|
|
|
/** Legacy core key -> the admin field the plugin declares. */
|
|
const ADMIN_MOVES: {
|
|
legacyKey: string;
|
|
field: string;
|
|
parse: Parse;
|
|
secret?: boolean;
|
|
}[] = [
|
|
{
|
|
legacyKey: "terminal_session_timeout_minutes",
|
|
field: "sessionTimeoutMinutes",
|
|
parse: asNumber,
|
|
},
|
|
{
|
|
legacyKey: "terminal_session_persistence_enabled",
|
|
field: "sessionPersistence",
|
|
parse: asBoolean,
|
|
},
|
|
{
|
|
legacyKey: "command_history_enabled",
|
|
field: "commandHistoryEnabled",
|
|
parse: asBoolean,
|
|
},
|
|
{
|
|
// The admin host defaults carried the new-host command history switch.
|
|
legacyKey: "host_defaults",
|
|
field: "commandHistoryForNewHosts",
|
|
parse: (raw) => {
|
|
const value = (asJson(raw) as { enableCommandHistory?: unknown } | null)
|
|
?.enableCommandHistory;
|
|
return typeof value === "boolean" ? value : undefined;
|
|
},
|
|
},
|
|
...(
|
|
[
|
|
["fontSize", "newHostFontSize"],
|
|
["fontFamily", "newHostFontFamily"],
|
|
["theme", "newHostTheme"],
|
|
["cursorStyle", "newHostCursorStyle"],
|
|
["cursorBlink", "newHostCursorBlink"],
|
|
["autoTmux", "newHostAutoTmux"],
|
|
] as const
|
|
).map(([legacyField, field]) => ({
|
|
// The terminal half of the admin host defaults, for new hosts.
|
|
legacyKey: "host_defaults",
|
|
field,
|
|
parse: (raw: string) => newHostDefault(legacyField, asJson(raw)),
|
|
})),
|
|
{ legacyKey: "touch_input_settings", field: "touchInput", parse: asJson },
|
|
{
|
|
legacyKey: "terminal_image_storage_mode",
|
|
field: "imageStorageMode",
|
|
parse: asString,
|
|
},
|
|
{
|
|
legacyKey: "terminal_image_local_dir",
|
|
field: "imageLocalDir",
|
|
parse: asString,
|
|
secret: true,
|
|
},
|
|
{
|
|
legacyKey: "terminal_image_host_path",
|
|
field: "imageHostPath",
|
|
parse: asString,
|
|
},
|
|
{ legacyKey: "terminal_image_ttl_ms", field: "imageTtlMs", parse: asNumber },
|
|
{
|
|
legacyKey: "terminal_image_max_count",
|
|
field: "imageMaxCount",
|
|
parse: asNumber,
|
|
},
|
|
{
|
|
legacyKey: "terminal_image_max_storage_bytes",
|
|
field: "imageMaxBytes",
|
|
parse: asNumber,
|
|
},
|
|
];
|
|
|
|
interface LegacyTerminalHostRow {
|
|
id: number;
|
|
enable_terminal: number | boolean | null;
|
|
enable_terminal_toolbar: number | boolean | null;
|
|
enable_command_history: number | boolean | null;
|
|
}
|
|
|
|
/** A column that was never set keeps the old default, on. */
|
|
function isOn(value: number | boolean | null): boolean {
|
|
return legacyFlag(value, true);
|
|
}
|
|
|
|
export interface SshTerminalSettingsMigrationResult {
|
|
moved: string[];
|
|
skipped: string[];
|
|
hostsMoved: number;
|
|
hostsSkipped: number;
|
|
}
|
|
|
|
export async function runSshTerminalSettingsMigration(): Promise<SshTerminalSettingsMigrationResult> {
|
|
const result: SshTerminalSettingsMigrationResult = {
|
|
moved: [],
|
|
skipped: [],
|
|
hostsMoved: 0,
|
|
hostsSkipped: 0,
|
|
};
|
|
|
|
try {
|
|
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
|
// until the plugin row exists.
|
|
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
|
if (!plugin) return result;
|
|
|
|
const settingsRepository = createCurrentSettingsRepository();
|
|
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
|
|
|
for (const move of ADMIN_MOVES) {
|
|
const legacyValue = await settingsRepository.get(move.legacyKey);
|
|
if (legacyValue === null || legacyValue === "") continue;
|
|
|
|
const existing = await pluginSettingsRepository.get(
|
|
PLUGIN_ID,
|
|
"admin",
|
|
null,
|
|
move.field,
|
|
);
|
|
if (existing && existing.value !== null) {
|
|
result.skipped.push(move.legacyKey);
|
|
continue;
|
|
}
|
|
|
|
const parsed = move.parse(legacyValue);
|
|
if (parsed === undefined) {
|
|
result.skipped.push(move.legacyKey);
|
|
continue;
|
|
}
|
|
|
|
const stored = move.secret
|
|
? await encryptSystemSecret(String(parsed))
|
|
: parsed;
|
|
await pluginSettingsRepository.set(
|
|
PLUGIN_ID,
|
|
"admin",
|
|
null,
|
|
move.field,
|
|
JSON.stringify(stored),
|
|
!!move.secret,
|
|
);
|
|
result.moved.push(move.legacyKey);
|
|
}
|
|
|
|
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
|
// stops declaring these columns.
|
|
const rows = await selectLegacyRows<LegacyTerminalHostRow>(sql`
|
|
SELECT id, enable_terminal, enable_terminal_toolbar, enable_command_history
|
|
FROM ssh_data
|
|
WHERE enable_terminal = false
|
|
OR enable_terminal_toolbar = false
|
|
OR enable_command_history = false
|
|
`);
|
|
|
|
for (const row of rows) {
|
|
const scopeId = String(row.id);
|
|
const existing = await pluginSettingsRepository.get(
|
|
PLUGIN_ID,
|
|
"host",
|
|
scopeId,
|
|
"enableTerminal",
|
|
);
|
|
if (existing && existing.value !== null) {
|
|
result.hostsSkipped++;
|
|
continue;
|
|
}
|
|
|
|
const values: Record<string, boolean> = {
|
|
enableTerminal: isOn(row.enable_terminal),
|
|
enableTerminalToolbar: isOn(row.enable_terminal_toolbar),
|
|
enableCommandHistory: isOn(row.enable_command_history),
|
|
};
|
|
for (const [key, value] of Object.entries(values)) {
|
|
await pluginSettingsRepository.set(
|
|
PLUGIN_ID,
|
|
"host",
|
|
scopeId,
|
|
key,
|
|
JSON.stringify(value),
|
|
);
|
|
}
|
|
result.hostsMoved++;
|
|
}
|
|
|
|
if (result.moved.length > 0 || result.hostsMoved > 0) {
|
|
databaseLogger.info(
|
|
`Moved ${result.moved.length} terminal setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
|
|
{
|
|
operation: "ssh_terminal_settings_migration",
|
|
moved: result.moved.join(", "),
|
|
hostsMoved: result.hostsMoved,
|
|
},
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// A failed migration must not stop the backend. The terminal falls back
|
|
// to its defaults, which is a visible, recoverable state.
|
|
databaseLogger.warn("SSH terminal settings migration failed", {
|
|
operation: "ssh_terminal_settings_migration",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|