mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: drop the 2.8 columns, moves and aliases kept for a downgrade
This commit is contained in:
1 parent
d3d8269cb7
commit
61f46d2517
196 files changed
+29909
-10511
No files matched your search
+267
-120
@@ -24,6 +24,16 @@ import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
||||
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
|
||||
import { connectRemoteDatabase } from "./connect.js";
|
||||
import { runRemoteMigrations } from "./migrate.js";
|
||||
import { assertNotPre29Database } from "./upgrade-guard.js";
|
||||
import {
|
||||
LEGACY_USERNAME_PLACEHOLDER_UPDATES,
|
||||
legacySudoMoves,
|
||||
RETIRED_COLUMNS,
|
||||
RETIRED_SETTINGS,
|
||||
REMOTE_RETIRED_COLUMNS,
|
||||
type LegacySudoRow,
|
||||
} from "./retired-columns.js";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import type { PortableDatabase } from "../repositories/database-context.js";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
@@ -217,22 +227,21 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
await assertNotPre29Database(
|
||||
(name) =>
|
||||
!!sqlite
|
||||
.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
)
|
||||
.get(name),
|
||||
);
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
oidc_identifier TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
issuer_url TEXT,
|
||||
authorization_url TEXT,
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -308,7 +317,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
force_keyboard_interactive TEXT,
|
||||
status_check_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status_check_interval INTEGER,
|
||||
terminal_config TEXT,
|
||||
notes TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
@@ -357,12 +365,10 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sort_order INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||
@@ -672,6 +678,14 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// 2.9.1 added this fix, so a 2.9.0 install has not run it yet. It reads the
|
||||
// column migrateSchema drops.
|
||||
if (columnNames("users").includes("oidc_identifier")) {
|
||||
const { runSsoLegacyIdentityMigration } =
|
||||
await import("../../upgrade/sso-legacy-identity-migration.js");
|
||||
await runSsoLegacyIdentityMigration();
|
||||
}
|
||||
|
||||
migrateSchema();
|
||||
vacuumIfFreelistBloated();
|
||||
|
||||
@@ -782,6 +796,135 @@ const relaxPluginGrantGrantedBy = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const columnNames = (table: string): string[] =>
|
||||
(
|
||||
sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((col) => col.name);
|
||||
|
||||
/**
|
||||
* ssh_folders.credential_id became the folder's auth default. It carries a
|
||||
* foreign key, which ALTER TABLE DROP COLUMN refuses, so the table is rebuilt.
|
||||
*/
|
||||
const dropFolderCredentialColumn = () => {
|
||||
const columns = columnNames("ssh_folders");
|
||||
if (!columns.includes("credential_id")) return;
|
||||
|
||||
const kept = [
|
||||
"id",
|
||||
"user_id",
|
||||
"name",
|
||||
"color",
|
||||
"icon",
|
||||
"sort_order",
|
||||
"sync_id",
|
||||
"local_only",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
].filter((name) => columns.includes(name));
|
||||
const list = kept.map((name) => `"${name}"`).join(", ");
|
||||
|
||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
sqlite.exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE ssh_folders_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER,
|
||||
sync_id TEXT,
|
||||
local_only INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO ssh_folders_new (${list}) SELECT ${list} FROM ssh_folders;
|
||||
DROP TABLE ssh_folders;
|
||||
ALTER TABLE ssh_folders_new RENAME TO ssh_folders;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_folders_sync_id ON ssh_folders(sync_id);
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (error) {
|
||||
if (sqlite.inTransaction) sqlite.exec("ROLLBACK");
|
||||
databaseLogger.warn("Failed to drop ssh_folders.credential_id", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
};
|
||||
|
||||
const moveLegacySudoPasswords = () => {
|
||||
if (!columnNames("ssh_data").includes("terminal_config")) return;
|
||||
try {
|
||||
const rows = sqlite
|
||||
.prepare(
|
||||
"SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'",
|
||||
)
|
||||
.all() as LegacySudoRow[];
|
||||
const update = sqlite.prepare(
|
||||
"UPDATE ssh_data SET sudo_password = ? WHERE id = ?",
|
||||
);
|
||||
for (const move of legacySudoMoves(rows)) {
|
||||
update.run(move.sudoPassword, move.id);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to move legacy sudo passwords", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const dropRetiredColumns = () => {
|
||||
moveLegacySudoPasswords();
|
||||
for (const [table, retired] of Object.entries(RETIRED_COLUMNS)) {
|
||||
const columns = new Set(columnNames(table));
|
||||
for (const column of retired) {
|
||||
if (!columns.has(column)) continue;
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table} DROP COLUMN "${column}"`);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
dropFolderCredentialColumn();
|
||||
|
||||
// 2.8 spelled $external.username as $oidc.preferred_username.
|
||||
for (const statement of LEGACY_USERNAME_PLACEHOLDER_UPDATES) {
|
||||
try {
|
||||
sqlite.exec(statement);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to rename a legacy username placeholder", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`DELETE FROM settings WHERE key IN (${RETIRED_SETTINGS.map(() => "?").join(", ")})`,
|
||||
)
|
||||
.run(...RETIRED_SETTINGS);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to delete retired settings", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
repairSnippetsNoteColumn(sqlite);
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
@@ -789,7 +932,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "language", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
|
||||
@@ -805,31 +947,15 @@ const migrateSchema = () => {
|
||||
"INTEGER",
|
||||
);
|
||||
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
const hadRegisteredAtColumn = (() => {
|
||||
try {
|
||||
sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
|
||||
@@ -878,9 +1004,7 @@ const migrateSchema = () => {
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
||||
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
@@ -926,8 +1050,6 @@ const migrateSchema = () => {
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
@@ -1145,19 +1267,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1177,7 +1286,6 @@ const migrateSchema = () => {
|
||||
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
|
||||
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
|
||||
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
|
||||
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
|
||||
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
|
||||
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
|
||||
@@ -1459,76 +1567,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||
|
||||
try {
|
||||
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}>;
|
||||
const legacyNotNullColumns = new Set([
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
"identifier_path",
|
||||
"name_path",
|
||||
"scopes",
|
||||
]);
|
||||
const hasStaleNotNull = usersTableInfo.some(
|
||||
(col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
|
||||
);
|
||||
|
||||
if (hasStaleNotNull) {
|
||||
const tempTableName = "users_temp_migration";
|
||||
const columnDefs = usersTableInfo
|
||||
.map((col) => {
|
||||
const parts = [`"${col.name}"`, col.type || "TEXT"];
|
||||
if (col.pk === 1) parts.push("PRIMARY KEY");
|
||||
if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
|
||||
parts.push("NOT NULL");
|
||||
}
|
||||
if (col.dflt_value !== null) {
|
||||
parts.push(`DEFAULT ${col.dflt_value}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
})
|
||||
.join(",\n ");
|
||||
const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
|
||||
|
||||
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ${tempTableName} (
|
||||
${columnDefs}
|
||||
);
|
||||
|
||||
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
|
||||
|
||||
DROP TABLE users;
|
||||
|
||||
ALTER TABLE ${tempTableName} RENAME TO users;
|
||||
`);
|
||||
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
||||
|
||||
databaseLogger.info(
|
||||
"Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
|
||||
{
|
||||
operation: "schema_migration_users_oidc_nullable",
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (migrationError) {
|
||||
databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
|
||||
operation: "schema_migration",
|
||||
error: migrationError,
|
||||
});
|
||||
}
|
||||
|
||||
// --- alerts begin ---
|
||||
// alert_rules, alert_rule_channels and alert_firings were only ever created
|
||||
// here, never declared in schema.ts, so they never existed on Postgres or
|
||||
@@ -1686,6 +1724,8 @@ const migrateSchema = () => {
|
||||
// they referenced, which defeats the point of keeping them.
|
||||
migrateAuditRetention(sqlite);
|
||||
|
||||
dropRetiredColumns();
|
||||
|
||||
// Runs last so every table and column added above already exists.
|
||||
createPerformanceIndexes(sqlite);
|
||||
|
||||
@@ -1832,6 +1872,94 @@ async function initializeDatabase(): Promise<void> {
|
||||
* synchronously, which better-sqlite3 allows and no remote driver does, so the
|
||||
* table is loaded once here before anything asks for it.
|
||||
*/
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
// node-postgres returns { rows }, mysql2 returns [rows, fields].
|
||||
if (Array.isArray(result)) return result[0] as T[];
|
||||
return ((result as { rows?: unknown[] }).rows ?? []) as T[];
|
||||
}
|
||||
|
||||
async function moveRemoteLegacySudoPasswords(remote: {
|
||||
execute: (query: SQL) => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
let rows: LegacySudoRow[];
|
||||
try {
|
||||
rows = resultRows<LegacySudoRow>(
|
||||
await remote.execute(
|
||||
sql`SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// A database created after the column left never had it.
|
||||
return;
|
||||
}
|
||||
for (const move of legacySudoMoves(rows)) {
|
||||
await remote.execute(
|
||||
sql`UPDATE ssh_data SET sudo_password = ${move.sudoPassword} WHERE id = ${move.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
||||
* longer knows them. Dropped by hand, only where they still exist.
|
||||
*/
|
||||
async function dropRemoteRetired(
|
||||
dialect: Exclude<DatabaseDialect, "sqlite">,
|
||||
remote: { execute: (query: SQL) => Promise<unknown> },
|
||||
): Promise<void> {
|
||||
const tables = Object.keys(REMOTE_RETIRED_COLUMNS);
|
||||
const schema =
|
||||
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
|
||||
let present: Array<{ table_name: string; column_name: string }> = [];
|
||||
try {
|
||||
present = resultRows(
|
||||
await remote.execute(
|
||||
sql`SELECT table_name AS table_name, column_name AS column_name FROM information_schema.columns WHERE table_schema = ${schema} AND table_name IN (${sql.join(
|
||||
tables.map((table) => sql`${table}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not list retired columns", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existing = new Set(
|
||||
present.map((row) => `${row.table_name}.${row.column_name}`),
|
||||
);
|
||||
for (const [table, columns] of Object.entries(REMOTE_RETIRED_COLUMNS)) {
|
||||
for (const column of columns) {
|
||||
if (!existing.has(`${table}.${column}`)) continue;
|
||||
try {
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await remote.execute(
|
||||
sql`DELETE FROM settings WHERE ${sql.identifier("key")} IN (${sql.join(
|
||||
RETIRED_SETTINGS.map((key) => sql`${key}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to delete retired settings", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRemoteDatabase(
|
||||
dialect: Exclude<DatabaseDialect, "sqlite">,
|
||||
): Promise<void> {
|
||||
@@ -1841,7 +1969,26 @@ async function initializeRemoteDatabase(
|
||||
});
|
||||
|
||||
db = await connectRemoteDatabase(dialect);
|
||||
const remote = db as unknown as { execute: (query: SQL) => Promise<unknown> };
|
||||
await assertNotPre29Database(async (name) => {
|
||||
try {
|
||||
await remote.execute(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await moveRemoteLegacySudoPasswords(remote);
|
||||
try {
|
||||
await remote.execute(sql`SELECT oidc_identifier FROM users WHERE 1 = 0`);
|
||||
const { runSsoLegacyIdentityMigration } =
|
||||
await import("../../upgrade/sso-legacy-identity-migration.js");
|
||||
await runSsoLegacyIdentityMigration();
|
||||
} catch {
|
||||
// The column is gone: the move ran before it was dropped.
|
||||
}
|
||||
await runRemoteMigrations(dialect, db);
|
||||
await dropRemoteRetired(dialect, remote);
|
||||
|
||||
// Imported here rather than at the top: factory.ts imports getDb from this
|
||||
// module, and a static import would close the cycle at module-load time.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* What 26.10.0 removes that 2.9.0 kept so a downgrade to 2.8 lost nothing.
|
||||
* Every 2.9 boot copied it into plugin settings, and the upgrade guard only
|
||||
* opens a database 2.9 has seen.
|
||||
*
|
||||
* Kept on purpose: columns a move only reads once the owner's data key opens
|
||||
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
||||
* domain), so a user who never signed in to 2.9 still gets them, and
|
||||
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
|
||||
*/
|
||||
|
||||
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
||||
export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
ssh_data: [
|
||||
"allow_session_sharing",
|
||||
"autostart_key",
|
||||
"autostart_key_password",
|
||||
"autostart_password",
|
||||
"default_path",
|
||||
"docker_config",
|
||||
"enable_ai_assistant",
|
||||
"enable_command_history",
|
||||
"enable_docker",
|
||||
"enable_file_manager",
|
||||
"enable_proxmox",
|
||||
"enable_proxmox_stats",
|
||||
"enable_rdp",
|
||||
"enable_session_logging",
|
||||
"enable_telnet",
|
||||
"enable_terminal",
|
||||
"enable_terminal_toolbar",
|
||||
"enable_tmux_monitor",
|
||||
"enable_tunnel",
|
||||
"enable_vnc",
|
||||
"enable_web_ui",
|
||||
"guacamole_config",
|
||||
"ignore_cert",
|
||||
"mac_address",
|
||||
"proxmox_config",
|
||||
"proxmox_stats_config",
|
||||
"rdp_ignore_cert",
|
||||
"rdp_port",
|
||||
"rdp_security",
|
||||
"scp_legacy",
|
||||
"security",
|
||||
"show_docker_in_sidebar",
|
||||
"show_file_manager_in_sidebar",
|
||||
"show_server_stats_in_sidebar",
|
||||
"show_terminal_in_sidebar",
|
||||
"show_tunnel_in_sidebar",
|
||||
"stats_config",
|
||||
"telnet_port",
|
||||
"tunnel_connections",
|
||||
"use_warpgate",
|
||||
"vnc_port",
|
||||
"web_ui_config",
|
||||
"wol_broadcast_address",
|
||||
],
|
||||
user_preferences: [
|
||||
"ai_assistant_enabled",
|
||||
"ai_read_only_commands",
|
||||
"rdp_defaults",
|
||||
],
|
||||
};
|
||||
|
||||
/** Everything SQLite drops; the first part the drizzle migrations drop too. */
|
||||
export const RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
users: [
|
||||
"is_oidc",
|
||||
"oidc_identifier",
|
||||
"sso_provider_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
"identifier_path",
|
||||
"name_path",
|
||||
"scopes",
|
||||
],
|
||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||
user_preferences: [
|
||||
"command_autocomplete",
|
||||
"terminal_defaults",
|
||||
"custom_themes",
|
||||
"confirm_snippet_execution",
|
||||
"terminal_macros",
|
||||
...REMOTE_RETIRED_COLUMNS.user_preferences,
|
||||
],
|
||||
ssh_data: [
|
||||
"terminal_config",
|
||||
"quick_actions",
|
||||
...REMOTE_RETIRED_COLUMNS.ssh_data,
|
||||
],
|
||||
};
|
||||
|
||||
/** Settings rows the plugins copied, and the markers of the copies. */
|
||||
export const RETIRED_SETTINGS = [
|
||||
"host_defaults",
|
||||
"analytics_enabled",
|
||||
"analytics_instance_id",
|
||||
"terminal_session_timeout_minutes",
|
||||
"terminal_session_persistence_enabled",
|
||||
"command_history_enabled",
|
||||
"touch_input_settings",
|
||||
"terminal_image_storage_mode",
|
||||
"terminal_image_local_dir",
|
||||
"terminal_image_host_path",
|
||||
"terminal_image_ttl_ms",
|
||||
"terminal_image_max_count",
|
||||
"terminal_image_max_storage_bytes",
|
||||
"session_sharing_globally_enabled",
|
||||
"guac_enabled",
|
||||
"guac_url",
|
||||
"step_ca_url",
|
||||
"step_ca_fingerprint",
|
||||
"step_ca_provisioner",
|
||||
"step_ca_private_endpoint_allowlist",
|
||||
"acme_ssl_settings",
|
||||
"global_metrics_interval",
|
||||
"metrics_history_retention_days",
|
||||
"tailscale_api_base_url",
|
||||
"tailscale_api_key",
|
||||
"ai_globally_enabled",
|
||||
"ai_private_endpoint_allowlist",
|
||||
"oidc_silent_login_default",
|
||||
"host_status_config_migrated",
|
||||
];
|
||||
|
||||
/**
|
||||
* 2.8 editors kept a host's sudo password inside terminal_config, and 2.9
|
||||
* still read it from there. Before the column goes it is copied into
|
||||
* sudo_password, as plaintext, which the field encryption reads as it is
|
||||
* and encrypts on the next save.
|
||||
*/
|
||||
export function legacySudoPassword(terminalConfig: unknown): string | null {
|
||||
let parsed = terminalConfig;
|
||||
try {
|
||||
while (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const value = (parsed as { sudoPassword?: unknown }).sudoPassword;
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
export interface LegacySudoRow {
|
||||
id: number;
|
||||
terminal_config: unknown;
|
||||
sudo_password: unknown;
|
||||
}
|
||||
|
||||
/** The hosts whose sudo password still only lives in terminal_config. */
|
||||
export function legacySudoMoves(
|
||||
rows: LegacySudoRow[],
|
||||
): Array<{ id: number; sudoPassword: string }> {
|
||||
return rows.flatMap((row) => {
|
||||
if (row.sudo_password) return [];
|
||||
const sudoPassword = legacySudoPassword(row.terminal_config);
|
||||
return sudoPassword ? [{ id: Number(row.id), sudoPassword }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the 2.8 `$oidc.preferred_username` placeholder to
|
||||
* `$external.username` wherever a host username is saved. Plain SQL that
|
||||
* runs the same on SQLite, Postgres and MySQL; the drizzle migrations carry
|
||||
* a copy.
|
||||
*/
|
||||
export const LEGACY_USERNAME_PLACEHOLDER_UPDATES = [
|
||||
"UPDATE ssh_data SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
|
||||
"UPDATE ssh_credentials SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
|
||||
"UPDATE host_defaults SET value = REPLACE(value, '$oidc.preferred_username', '$external.username') WHERE value LIKE '%$oidc.preferred_username%'",
|
||||
];
|
||||
@@ -26,19 +26,6 @@ export const users = mysqlTable("users", {
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
|
||||
isOidc: boolean("is_oidc").notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
ssoProviderId: int("sso_provider_id"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
authorizationUrl: text("authorization_url"),
|
||||
tokenUrl: text("token_url"),
|
||||
identifierPath: text("identifier_path"),
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
@@ -60,9 +47,8 @@ export const sessions = mysqlTable(
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: int("sso_provider_id"),
|
||||
// JSON of the identity provider's logout claims, for back-channel logout.
|
||||
externalSessionRef: text("external_session_ref"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
@@ -196,11 +182,9 @@ export const hosts = mysqlTable(
|
||||
.notNull()
|
||||
.default(true),
|
||||
statusCheckInterval: int("status_check_interval"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
// SSH connection options core's connect pipeline reads (keepalive,
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
@@ -377,9 +361,6 @@ export const sshFolders = mysqlTable(
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
credentialId: int("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Manual drag-to-reorder position among sibling folders. Null falls back
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: int("sort_order"),
|
||||
@@ -712,7 +693,6 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
storageMode: text("storage_mode"),
|
||||
commandAutocomplete: boolean("command_autocomplete"),
|
||||
commandPaletteEnabled: boolean("command_palette_enabled"),
|
||||
showHostTags: boolean("show_host_tags"),
|
||||
hostTrayOnClick: boolean("host_tray_on_click"),
|
||||
@@ -720,16 +700,12 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
|
||||
showPinAppRailButton: boolean("show_pin_app_rail_button"),
|
||||
foldersCollapsed: boolean("folders_collapsed"),
|
||||
confirmSnippetExecution: boolean("confirm_snippet_execution"),
|
||||
disableUpdateCheck: boolean("disable_update_check"),
|
||||
confirmTabClose: boolean("confirm_tab_close"),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -26,19 +26,6 @@ export const users = pgTable("users", {
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
|
||||
isOidc: boolean("is_oidc").notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
authorizationUrl: text("authorization_url"),
|
||||
tokenUrl: text("token_url"),
|
||||
identifierPath: text("identifier_path"),
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
@@ -60,9 +47,8 @@ export const sessions = pgTable(
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
// JSON of the identity provider's logout claims, for back-channel logout.
|
||||
externalSessionRef: text("external_session_ref"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -196,11 +182,9 @@ export const hosts = pgTable(
|
||||
.notNull()
|
||||
.default(true),
|
||||
statusCheckInterval: integer("status_check_interval"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
// SSH connection options core's connect pipeline reads (keepalive,
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
@@ -377,9 +361,6 @@ export const sshFolders = pgTable(
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Manual drag-to-reorder position among sibling folders. Null falls back
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: integer("sort_order"),
|
||||
@@ -712,7 +693,6 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
storageMode: text("storage_mode"),
|
||||
commandAutocomplete: boolean("command_autocomplete"),
|
||||
commandPaletteEnabled: boolean("command_palette_enabled"),
|
||||
showHostTags: boolean("show_host_tags"),
|
||||
hostTrayOnClick: boolean("host_tray_on_click"),
|
||||
@@ -720,16 +700,12 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
|
||||
showPinAppRailButton: boolean("show_pin_app_rail_button"),
|
||||
foldersCollapsed: boolean("folders_collapsed"),
|
||||
confirmSnippetExecution: boolean("confirm_snippet_execution"),
|
||||
disableUpdateCheck: boolean("disable_update_check"),
|
||||
confirmTabClose: boolean("confirm_tab_close"),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -14,19 +14,6 @@ export const users = sqliteTable("users", {
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
authorizationUrl: text("authorization_url"),
|
||||
tokenUrl: text("token_url"),
|
||||
identifierPath: text("identifier_path"),
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: integer("donation_modal_dismissed", {
|
||||
mode: "boolean",
|
||||
@@ -50,9 +37,8 @@ export const sessions = sqliteTable(
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
// JSON of the identity provider's logout claims, for back-channel logout.
|
||||
externalSessionRef: text("external_session_ref"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -188,11 +174,9 @@ export const hosts = sqliteTable(
|
||||
.notNull()
|
||||
.default(true),
|
||||
statusCheckInterval: integer("status_check_interval"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
// SSH connection options core's connect pipeline reads (keepalive,
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
@@ -369,9 +353,6 @@ export const sshFolders = sqliteTable(
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Manual drag-to-reorder position among sibling folders. Null falls back
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: integer("sort_order"),
|
||||
@@ -704,7 +685,6 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
storageMode: text("storage_mode"),
|
||||
commandAutocomplete: integer("command_autocomplete", { mode: "boolean" }),
|
||||
commandPaletteEnabled: integer("command_palette_enabled", { mode: "boolean" }),
|
||||
showHostTags: integer("show_host_tags", { mode: "boolean" }),
|
||||
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
|
||||
@@ -716,16 +696,12 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
|
||||
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
|
||||
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
|
||||
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 26.10.0 dropped the columns 2.9.0 kept for a downgrade to 2.8, and the
|
||||
* one-time 2.8 moves that copied them into plugins went with them. A 2.8
|
||||
* database has to pass through 2.9 first, or that data is lost.
|
||||
*
|
||||
* A 2.9 database has the plugins table; a 2.8 one has hosts but no plugins.
|
||||
*/
|
||||
export const PRE_29_DATABASE_ERROR =
|
||||
"This database is from Termix 2.8 or older. Upgrade to Termix 2.9.1 and start it once so it can move your data, then upgrade to this version.";
|
||||
|
||||
export async function assertNotPre29Database(
|
||||
hasTable: (name: string) => boolean | Promise<boolean>,
|
||||
): Promise<void> {
|
||||
if ((await hasTable("ssh_data")) && !(await hasTable("plugins"))) {
|
||||
throw new Error(PRE_29_DATABASE_ERROR);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user