mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -7,10 +7,6 @@ import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import hostRoutes from "./routes/host.js";
|
||||
import {
|
||||
registerTermixIdCompatRoutes,
|
||||
registerVaultCompatRoutes,
|
||||
} from "./routes/host-compat-routes.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
@@ -44,6 +40,10 @@ import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
|
||||
import { DatabaseMigration } from "../utils/database-migration.js";
|
||||
import { UserDataExport } from "../utils/user-data-export.js";
|
||||
import {
|
||||
importUserPluginRows,
|
||||
writeUserPluginTables,
|
||||
} from "../plugins/user-data.js";
|
||||
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
||||
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
||||
import {
|
||||
@@ -65,8 +65,7 @@ import type {
|
||||
GitHubAPIResponse,
|
||||
AuthenticatedRequest,
|
||||
} from "../../types/index.js";
|
||||
import { DatabaseSaveTrigger, getDb } from "./db/index.js";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { DatabaseSaveTrigger } from "./db/index.js";
|
||||
import Database from "better-sqlite3";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
@@ -867,43 +866,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE p_file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE p_file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE p_file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE p_file_manager_transfer_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
source_host_id INTEGER NOT NULL,
|
||||
dest_host_id INTEGER NOT NULL,
|
||||
dest_path TEXT NOT NULL,
|
||||
dest_path_label TEXT NOT NULL,
|
||||
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1043,86 +1005,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
// The file manager plugin owns these tables now (p_file_manager_*);
|
||||
// read them by raw SQL rather than importing plugin code into core.
|
||||
const drizzleDb = getDb();
|
||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||
drizzleDb.all<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
host_id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
last_opened: string;
|
||||
}>(
|
||||
sql`SELECT id, user_id, host_id, name, path, last_opened FROM p_file_manager_recent WHERE user_id = ${userId}`,
|
||||
),
|
||||
drizzleDb.all<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
host_id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
pinned_at: string;
|
||||
}>(
|
||||
sql`SELECT id, user_id, host_id, name, path, pinned_at FROM p_file_manager_pinned WHERE user_id = ${userId}`,
|
||||
),
|
||||
drizzleDb.all<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
host_id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
sql`SELECT id, user_id, host_id, name, path, created_at FROM p_file_manager_shortcuts WHERE user_id = ${userId}`,
|
||||
),
|
||||
]);
|
||||
|
||||
const insertRecent = exportDb.prepare(`
|
||||
INSERT INTO p_file_manager_recent (id, user_id, host_id, name, path, last_opened)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const item of recentFiles) {
|
||||
insertRecent.run(
|
||||
item.id,
|
||||
item.user_id,
|
||||
item.host_id,
|
||||
item.name,
|
||||
item.path,
|
||||
item.last_opened,
|
||||
);
|
||||
}
|
||||
|
||||
const insertPinned = exportDb.prepare(`
|
||||
INSERT INTO p_file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const item of pinnedFiles) {
|
||||
insertPinned.run(
|
||||
item.id,
|
||||
item.user_id,
|
||||
item.host_id,
|
||||
item.name,
|
||||
item.path,
|
||||
item.pinned_at,
|
||||
);
|
||||
}
|
||||
|
||||
const insertShortcut = exportDb.prepare(`
|
||||
INSERT INTO p_file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const item of shortcuts) {
|
||||
insertShortcut.run(
|
||||
item.id,
|
||||
item.user_id,
|
||||
item.host_id,
|
||||
item.name,
|
||||
item.path,
|
||||
item.created_at,
|
||||
);
|
||||
}
|
||||
// Rows the user owns in plugin tables travel with the export.
|
||||
await writeUserPluginTables(exportDb, userId);
|
||||
|
||||
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
|
||||
const alerts = await dismissedAlertRepository.listByUserId(userId);
|
||||
@@ -1332,7 +1216,7 @@ app.post(
|
||||
summary: {
|
||||
sshHostsImported: 0,
|
||||
sshCredentialsImported: 0,
|
||||
fileManagerItemsImported: 0,
|
||||
pluginItemsImported: 0,
|
||||
dismissedAlertsImported: 0,
|
||||
credentialUsageImported: 0,
|
||||
settingsImported: 0,
|
||||
@@ -1477,90 +1361,10 @@ app.post(
|
||||
);
|
||||
}
|
||||
|
||||
// The file manager plugin owns these tables now (p_file_manager_*).
|
||||
// Older export files still have the legacy name, so try that too.
|
||||
const fileManagerTables = [
|
||||
{
|
||||
tableNames: ["p_file_manager_recent", "file_manager_recent"],
|
||||
physicalTable: "p_file_manager_recent",
|
||||
dateColumn: "last_opened",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
tableNames: ["p_file_manager_pinned", "file_manager_pinned"],
|
||||
physicalTable: "p_file_manager_pinned",
|
||||
dateColumn: "pinned_at",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
tableNames: [
|
||||
"p_file_manager_shortcuts",
|
||||
"file_manager_shortcuts",
|
||||
],
|
||||
physicalTable: "p_file_manager_shortcuts",
|
||||
dateColumn: "created_at",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
];
|
||||
|
||||
const importDrizzleDb = getDb();
|
||||
|
||||
for (const {
|
||||
tableNames,
|
||||
physicalTable,
|
||||
dateColumn,
|
||||
key,
|
||||
} of fileManagerTables) {
|
||||
let importedItems: Array<Record<string, unknown>> | null = null;
|
||||
let sourceTable = "";
|
||||
for (const tableName of tableNames) {
|
||||
try {
|
||||
importedItems = importDb
|
||||
.prepare(`SELECT * FROM ${tableName}`)
|
||||
.all() as Array<Record<string, unknown>>;
|
||||
sourceTable = tableName;
|
||||
break;
|
||||
} catch {
|
||||
// try the next name
|
||||
}
|
||||
}
|
||||
|
||||
if (importedItems === null) {
|
||||
apiLogger.info(
|
||||
`${physicalTable} table not found in import file, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const item of importedItems) {
|
||||
try {
|
||||
const hostId = item.host_id as number;
|
||||
const path = item.path as string;
|
||||
const name =
|
||||
(item.name as string) || path.split("/").pop() || "Unknown";
|
||||
const dateValue =
|
||||
(item[dateColumn] as string) || new Date().toISOString();
|
||||
|
||||
const existing = await importDrizzleDb.all<{ id: number }>(
|
||||
sql`SELECT id FROM ${sql.raw(physicalTable)} WHERE user_id = ${userId} AND host_id = ${hostId} AND path = ${path}`,
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await importDrizzleDb.run(
|
||||
sql`INSERT INTO ${sql.raw(physicalTable)} (user_id, host_id, path, name, ${sql.raw(dateColumn)}) VALUES (${userId}, ${hostId}, ${path}, ${name}, ${dateValue})`,
|
||||
);
|
||||
result.summary[key]++;
|
||||
} catch (itemError) {
|
||||
result.summary.errors.push(
|
||||
`${sourceTable} import error: ${itemError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||
result.summary.pluginItemsImported += pluginRows.imported;
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const dismissedAlertRepository =
|
||||
createCurrentDismissedAlertRepository();
|
||||
@@ -1819,13 +1623,7 @@ app.use("/user-preferences", userPreferencesRoutes);
|
||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
const termixIdCompatRoutes = express.Router();
|
||||
registerTermixIdCompatRoutes(termixIdCompatRoutes);
|
||||
app.use("/termix-id", termixIdCompatRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
const vaultCompatRoutes = express.Router();
|
||||
registerVaultCompatRoutes(vaultCompatRoutes);
|
||||
app.use("/vault", vaultCompatRoutes);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
@@ -1842,8 +1640,12 @@ mountPluginLegacyPaths(app, () =>
|
||||
.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
legacyPaths: plugin.manifest.contributes?.http?.legacyPaths ?? [],
|
||||
legacyRedirects: plugin.manifest.contributes?.http?.legacyRedirects ?? [],
|
||||
}))
|
||||
.filter((plugin) => plugin.legacyPaths.length > 0),
|
||||
.filter(
|
||||
(plugin) =>
|
||||
plugin.legacyPaths.length > 0 || plugin.legacyRedirects.length > 0,
|
||||
),
|
||||
);
|
||||
|
||||
const frontendDistPaths = [
|
||||
|
||||
@@ -322,11 +322,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- file_manager_recent, file_manager_pinned, file_manager_shortcuts and
|
||||
-- transfer_recent are owned by the file-manager plugin now. Its own
|
||||
-- migration creates p_file_manager_* on a fresh install and adopts the
|
||||
-- legacy tables by rename on an upgrade.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -391,9 +386,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- command_history is owned by the ssh-terminal plugin, which adopts it
|
||||
-- as p_ssh_terminal_command_history in its first migration.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
@@ -1120,10 +1112,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1315,25 +1303,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the legacy "stats" tab type to "host-metrics" so previously saved
|
||||
// open tabs restore correctly after the Server Stats -> Host Metrics rename.
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM user_open_tabs LIMIT 1").get();
|
||||
const result = sqlite
|
||||
.prepare(
|
||||
"UPDATE user_open_tabs SET tab_type = 'host-metrics' WHERE tab_type = 'stats'",
|
||||
)
|
||||
.run();
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info(
|
||||
`Migrated ${result.changes} open tab(s) from 'stats' to 'host-metrics'`,
|
||||
{ operation: "open_tabs_tab_type_migration" },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// user_open_tabs table not present yet; nothing to migrate.
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1619,8 +1588,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("snippets", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
|
||||
|
||||
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
|
||||
// grants record whether a capability came from an admin or from bundling.
|
||||
@@ -1632,13 +1599,7 @@ const migrateSchema = () => {
|
||||
);
|
||||
relaxPluginGrantGrantedBy();
|
||||
|
||||
const syncIdTables = [
|
||||
"ssh_data",
|
||||
"ssh_credentials",
|
||||
"ssh_folders",
|
||||
"snippets",
|
||||
"snippet_folders",
|
||||
];
|
||||
const syncIdTables = ["ssh_data", "ssh_credentials", "ssh_folders"];
|
||||
|
||||
for (const table of syncIdTables) {
|
||||
try {
|
||||
@@ -1978,14 +1939,7 @@ export function getSqlite(): Database.Database {
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
export { db };
|
||||
export { DatabaseFileEncryption };
|
||||
export const databasePaths = {
|
||||
main: actualDbPath,
|
||||
encrypted: encryptedDbPath,
|
||||
directory: dbDir,
|
||||
inMemory: true,
|
||||
};
|
||||
|
||||
|
||||
export { saveMemoryDatabaseToFile };
|
||||
|
||||
|
||||
@@ -98,16 +98,12 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
columns: "user_id",
|
||||
},
|
||||
|
||||
|
||||
// Per-user history and file manager surfaces.
|
||||
{
|
||||
name: "idx_recent_activity_user_ts",
|
||||
table: "recent_activity",
|
||||
columns: "user_id, timestamp",
|
||||
},
|
||||
// file_manager_recent, file_manager_pinned, file_manager_shortcuts,
|
||||
// transfer_recent and command_history are owned by plugins now; their
|
||||
// adopted table definitions carry these same indexes across the rename.
|
||||
{
|
||||
name: "idx_user_open_tabs_user_id",
|
||||
table: "user_open_tabs",
|
||||
|
||||
@@ -271,9 +271,6 @@ export const hosts = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
|
||||
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
|
||||
|
||||
export const dismissedAlerts = mysqlTable(
|
||||
"dismissed_alerts",
|
||||
{
|
||||
@@ -317,7 +314,6 @@ export const sshCredentials = mysqlTable(
|
||||
|
||||
certPublicKey: text("cert_public_key"),
|
||||
|
||||
|
||||
usageCount: int("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
@@ -724,11 +720,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
|
||||
|
||||
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
|
||||
// termix-identity plugin (p_termix_identity_*).
|
||||
|
||||
// --- alerts begin ---
|
||||
export const notificationChannels = mysqlTable("notification_channels", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
@@ -745,12 +736,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
// homepage_items and homepage_layouts moved to the homepage plugin
|
||||
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
|
||||
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
@@ -768,14 +753,11 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
* Who may use or manage someone else's credential. Same shape as
|
||||
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||
* edits and re-shares it.
|
||||
* Who may use or manage someone else's credential. "use" attaches it to
|
||||
* hosts and connects, "manage" also edits and re-shares it.
|
||||
*/
|
||||
export const credentialAccess = mysqlTable(
|
||||
"credential_access",
|
||||
|
||||
@@ -272,9 +272,6 @@ export const hosts = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
|
||||
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
|
||||
|
||||
export const dismissedAlerts = pgTable(
|
||||
"dismissed_alerts",
|
||||
{
|
||||
@@ -318,7 +315,6 @@ export const sshCredentials = pgTable(
|
||||
|
||||
certPublicKey: text("cert_public_key"),
|
||||
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
@@ -725,11 +721,6 @@ export const uiPreferences = pgTable("ui_preferences", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
|
||||
|
||||
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
|
||||
// termix-identity plugin (p_termix_identity_*).
|
||||
|
||||
// --- alerts begin ---
|
||||
export const notificationChannels = pgTable("notification_channels", {
|
||||
id: serial("id").primaryKey(),
|
||||
@@ -746,12 +737,6 @@ export const notificationChannels = pgTable("notification_channels", {
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
// homepage_items and homepage_layouts moved to the homepage plugin
|
||||
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
|
||||
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
@@ -769,14 +754,11 @@ export const syncTombstones = pgTable("sync_tombstones", {
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
* Who may use or manage someone else's credential. Same shape as
|
||||
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||
* edits and re-shares it.
|
||||
* Who may use or manage someone else's credential. "use" attaches it to
|
||||
* hosts and connects, "manage" also edits and re-shares it.
|
||||
*/
|
||||
export const credentialAccess = pgTable(
|
||||
"credential_access",
|
||||
|
||||
@@ -264,9 +264,6 @@ export const hosts = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
|
||||
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
|
||||
|
||||
export const dismissedAlerts = sqliteTable(
|
||||
"dismissed_alerts",
|
||||
{
|
||||
@@ -310,7 +307,6 @@ export const sshCredentials = sqliteTable(
|
||||
|
||||
certPublicKey: text("cert_public_key", { length: 8192 }),
|
||||
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: text("sync_id").unique(),
|
||||
@@ -721,11 +717,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
|
||||
|
||||
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
|
||||
// termix-identity plugin (p_termix_identity_*).
|
||||
|
||||
// --- alerts begin ---
|
||||
export const notificationChannels = sqliteTable("notification_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
@@ -742,12 +733,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
// homepage_items and homepage_layouts moved to the homepage plugin
|
||||
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
|
||||
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
@@ -765,14 +750,11 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
* Who may use or manage someone else's credential. Same shape as
|
||||
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||
* edits and re-shares it.
|
||||
* Who may use or manage someone else's credential. "use" attaches it to
|
||||
* hosts and connects, "manage" also edits and re-shares it.
|
||||
*/
|
||||
export const credentialAccess = sqliteTable(
|
||||
"credential_access",
|
||||
|
||||
@@ -21,8 +21,6 @@ import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
@@ -163,9 +161,6 @@ export function createCurrentDismissedAlertRepository(): DismissedAlertRepositor
|
||||
);
|
||||
}
|
||||
|
||||
// File manager recent/pinned/shortcuts moved to the file-manager plugin.
|
||||
// Homepage items/layout and dashboard service links moved to the homepage plugin.
|
||||
|
||||
export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
return new HostFolderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -273,22 +268,6 @@ export function createCurrentRbacPermissionRepository(): RbacPermissionRepositor
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
|
||||
return new PluginRegistryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_registry_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
|
||||
return new PluginInstallCountRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryLazyWriteHook(
|
||||
"plugin_install_count_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentRbacAccessRepository(): RbacAccessRepository {
|
||||
return new RbacAccessRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -347,9 +326,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
|
||||
);
|
||||
}
|
||||
|
||||
// transfer_recent moved to the file-manager plugin.
|
||||
// tmux_session_tags moved to the tmux-monitor plugin.
|
||||
|
||||
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
|
||||
return new TrustedDeviceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface AppliedDefaultInput {
|
||||
/**
|
||||
* The two records that make plugin permissions outlive their plugin.
|
||||
*
|
||||
* `rbac_known_permissions` is why a role holding `ai.use` still saves while the
|
||||
* ai plugin is disabled. `rbac_applied_defaults` is why a default an admin
|
||||
* `rbac_known_permissions` is why a role holding a plugin permission
|
||||
* (`<plugin>.use`) still saves while its plugin is disabled. `rbac_applied_defaults` is why a default an admin
|
||||
* revoked is not handed back on the next boot. Neither has a foreign key to
|
||||
* `plugins`, because both have to survive the plugin going away.
|
||||
*/
|
||||
|
||||
@@ -9,13 +9,13 @@ export type UserExternalIdentityRecord =
|
||||
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
|
||||
|
||||
/** Subjects are indexed, so they stay within the portable key length. */
|
||||
export const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
|
||||
const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Keeps a subject within the index length. Longer ones are hashed, which is
|
||||
* stable, so the same provider answer always finds the same row.
|
||||
*/
|
||||
export function normalizeExternalSubject(subject: string): string {
|
||||
function normalizeExternalSubject(subject: string): string {
|
||||
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
|
||||
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,33 @@
|
||||
/**
|
||||
* The 2.8 login URLs that something outside Termix still depends on:
|
||||
* identity providers configured with the old callback and back-channel
|
||||
* logout URLs, and clients built against 2.8 such as Termix-Mobile. Each one
|
||||
* forwards to the login method or plugin route that replaced it, so none of
|
||||
* them can sign anyone in on its own.
|
||||
* The 2.8 login URLs clients built against 2.8 (Termix-Mobile) still call.
|
||||
* Each forwards to the login pipeline for the method it names, so none of
|
||||
* them can sign anyone in on its own. Old identity provider callbacks are
|
||||
* redirected by the owning plugin's manifest (contributes.http.legacyRedirects).
|
||||
*/
|
||||
|
||||
import type { Request, Router } from "express";
|
||||
import type { Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getRequestBasePath } from "../../utils/request-origin.js";
|
||||
import {
|
||||
listLegacySsoProviders,
|
||||
startRedirectLogin,
|
||||
verifyFormLogin,
|
||||
} from "./auth-routes.js";
|
||||
|
||||
/**
|
||||
* A path on this install, keeping the query string. Relative, so the browser
|
||||
* stays on the scheme and host it used, whatever a proxy told us.
|
||||
*/
|
||||
function pluginUrl(req: Request, path: string): string {
|
||||
const query = req.originalUrl.indexOf("?");
|
||||
return `${getRequestBasePath(req)}${path}${
|
||||
query >= 0 ? req.originalUrl.slice(query) : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function registerAuthCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* /users/{method}/authorize:
|
||||
* get:
|
||||
* summary: Get the default SSO provider's public configuration (2.8 route)
|
||||
* description: Kept for 2.8 clients. Redirects to the sso plugin's /plugin-api/sso/config.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the sso plugin.
|
||||
*/
|
||||
router.get("/oidc-config", (req, res) => {
|
||||
res.redirect(307, pluginUrl(req, "/plugin-api/sso/config"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/authorize:
|
||||
* get:
|
||||
* summary: Start an SSO login (2.8 route)
|
||||
* description: Kept for 2.8 clients such as Termix-Mobile. Same as /users/auth/oidc/start, with the provider in providerId.
|
||||
* summary: Start a redirect login (2.8 route)
|
||||
* description: Kept for 2.8 clients such as Termix-Mobile, which call /users/oidc/authorize. Same as /users/auth/{method}/start, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: method
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: providerId
|
||||
* schema:
|
||||
@@ -64,59 +40,19 @@ export function registerAuthCompatRoutes(router: Router): void {
|
||||
* 200:
|
||||
* description: The authorization URL in auth_url.
|
||||
* 404:
|
||||
* description: SSO is not available.
|
||||
* description: That login method is not available.
|
||||
*/
|
||||
router.get("/oidc/authorize", (req, res) =>
|
||||
router.get("/:method/authorize", (req, res) =>
|
||||
startRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
"oidc",
|
||||
String(req.params.method),
|
||||
typeof req.query.providerId === "string" && req.query.providerId
|
||||
? req.query.providerId
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/callback:
|
||||
* get:
|
||||
* summary: SSO callback (2.8 URL)
|
||||
* description: The redirect URI identity providers were set up with before 2.9. Permanently redirects to /plugin-api/sso/callback with the same query, so existing provider configurations keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin's callback.
|
||||
* post:
|
||||
* summary: SSO callback, form post (2.8 URL)
|
||||
* description: Same as the GET form.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin's callback.
|
||||
*/
|
||||
router.all("/oidc/callback", (req, res) => {
|
||||
res.redirect(308, pluginUrl(req, "/plugin-api/sso/callback"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/backchannel-logout:
|
||||
* post:
|
||||
* summary: OIDC back-channel logout (2.8 URL)
|
||||
* description: Permanently redirects to /plugin-api/sso/backchannel-logout, so identity providers set up before 2.9 keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin.
|
||||
*/
|
||||
router.post("/oidc/backchannel-logout", (req, res) => {
|
||||
res.redirect(308, pluginUrl(req, "/plugin-api/sso/backchannel-logout"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
@@ -140,25 +76,31 @@ export function registerAuthCompatRoutes(router: Router): void {
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ldap/login:
|
||||
* /users/{method}/login:
|
||||
* post:
|
||||
* summary: LDAP login (2.8 route)
|
||||
* description: Kept for 2.8 clients. Same as /users/auth/ldap/verify, with the provider in providerId.
|
||||
* summary: Form login (2.8 route)
|
||||
* description: Kept for 2.8 clients, which call /users/ldap/login. Same as /users/auth/{method}/verify, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: method
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 404:
|
||||
* description: LDAP is not available.
|
||||
* description: That login method is not available.
|
||||
*/
|
||||
router.post("/ldap/login", (req, res) =>
|
||||
router.post("/:method/login", (req, res) =>
|
||||
verifyFormLogin(
|
||||
req,
|
||||
res,
|
||||
"ldap",
|
||||
String(req.params.method),
|
||||
req.body?.providerId != null ? String(req.body.providerId) : null,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -130,7 +130,7 @@ router.get(
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
|
||||
* description: An activity type a plugin's tab declares, lowercase letters, digits and underscores.
|
||||
* hostId:
|
||||
* type: integer
|
||||
* hostName:
|
||||
@@ -177,7 +177,7 @@ router.post(
|
||||
if (result.status === "invalid_type") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
|
||||
"Invalid activity type. Use lowercase letters, digits and underscores.",
|
||||
});
|
||||
}
|
||||
if (result.status === "rate_limited") {
|
||||
|
||||
@@ -48,9 +48,8 @@ export async function deleteUserAndRelatedData(
|
||||
userId,
|
||||
);
|
||||
|
||||
// session_recordings is retained rather than deleted, by design: it
|
||||
// outlives the account. The session-recording plugin listens for
|
||||
// user.deleted and anonymizes its own rows instead of cascading.
|
||||
// Plugins drop or anonymize their own rows on user.deleted, or rely on
|
||||
// their refUser() foreign keys cascading.
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
pluginEvents.emit(TOPICS.userDeleted, { userId });
|
||||
|
||||
@@ -68,16 +67,9 @@ export async function deleteUserAndRelatedData(
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
|
||||
// file manager recent/pinned/shortcuts and transfer_recent cascade on the
|
||||
// user's refUser() foreign key, as the file-manager plugin's adopted
|
||||
// tables.
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||
|
||||
// snippets, snippet_folders and snippet_access cascade on the user's
|
||||
// refUser() foreign key, as the snippets plugin's adopted tables.
|
||||
|
||||
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
|
||||
@@ -88,12 +80,7 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
|
||||
// homepage_items, homepage_layouts, dashboard_service_links and
|
||||
// secret_sources cascade on the user's refUser() foreign key, as the
|
||||
// homepage and secret-sources plugins' adopted tables. The secret
|
||||
// source's token in ctx.secrets is cleaned up generically below, with
|
||||
// every other plugin_settings row for this user. The opkssh and vault
|
||||
// plugins' tables cascade the same way, and so do termix-identity's.
|
||||
// Plugin tables with a refUser() column cascade on the user row.
|
||||
|
||||
await createCurrentOpenTabRepository().deleteByUserId(userId);
|
||||
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* The 2.8 auth callback URLs something outside Termix still depends on: the
|
||||
* OPKSSH and Step CA redirect URIs registered with identity providers, and
|
||||
* the Vault one listed in Vault OIDC roles.
|
||||
* Each forwards to the plugin route that replaced it, so with the plugin off
|
||||
* it signs nobody in.
|
||||
*/
|
||||
|
||||
import type { Request, Router } from "express";
|
||||
import { getRequestBasePath } from "../../utils/request-origin.js";
|
||||
|
||||
/** A path on this install, keeping the query string. */
|
||||
function localUrl(req: Request, path: string): string {
|
||||
const query = req.originalUrl.indexOf("?");
|
||||
return `${getRequestBasePath(req)}${path}${
|
||||
query >= 0 ? req.originalUrl.slice(query) : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function registerHostAuthCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-callback:
|
||||
* get:
|
||||
* summary: OPKSSH callback (2.8 URL)
|
||||
* description: The redirect URI OPKSSH identity providers were set up with before 2.9. Redirects to /plugin-api/opkssh/callback with the same subpath and query, so existing provider configurations keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the opkssh plugin's callback.
|
||||
*/
|
||||
router.all(["/opkssh-callback", "/opkssh-callback/*rest"], (req, res) => {
|
||||
const rest = req.path.slice("/opkssh-callback".length);
|
||||
res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/step-ca-callback:
|
||||
* get:
|
||||
* summary: Step CA callback (2.8 URL)
|
||||
* description: The redirect URI Step CA identity providers were set up with before 2.9. Redirects to /plugin-api/step-ca/callback with the same query, so existing provider configurations keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the step-ca plugin's callback.
|
||||
*/
|
||||
router.get("/step-ca-callback", (req, res) => {
|
||||
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerVaultCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/oidc/callback:
|
||||
* get:
|
||||
* summary: Vault OIDC callback (2.8 URL)
|
||||
* description: The redirect URI Vault OIDC roles allowed before 2.9. Redirects to /plugin-api/vault/oidc/callback with the same query, so existing Vault roles keep working.
|
||||
* tags:
|
||||
* - Vault
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the vault plugin's callback.
|
||||
*/
|
||||
router.get("/oidc/callback", (req, res) => {
|
||||
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2.8 Termix ID resolver URLs, which servers fetch from provisioning
|
||||
* scripts. Permanently redirected to the termix-identity plugin's public
|
||||
* resolver; curl -L follows them.
|
||||
*/
|
||||
export function registerTermixIdCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /termix-id/u/{handle}:
|
||||
* get:
|
||||
* summary: Termix ID resolver (2.8 URL)
|
||||
* description: Permanently redirects to /plugin-api/termix-identity/u/{handle}, and likewise for /termix-id/u/{handle}/{algo} and /termix-id/u/{handle}/ca, so servers provisioned with the old URL keep working.
|
||||
* tags:
|
||||
* - Termix ID
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: handle
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the termix-identity plugin's resolver.
|
||||
*/
|
||||
router.get(["/u/:handle", "/u/:handle/:algo"], (req, res) => {
|
||||
const rest = req.path.slice("/u".length);
|
||||
res.redirect(308, localUrl(req, `/plugin-api/termix-identity/u${rest}`));
|
||||
});
|
||||
}
|
||||
@@ -396,10 +396,7 @@ export function registerHostFolderRoutes(
|
||||
const hostIds = hostsToDelete.map((host) => host.id);
|
||||
|
||||
if (hostIds.length > 0) {
|
||||
// file manager recent/pinned/shortcuts, transfer_recent, command
|
||||
// history and session recordings cascade on the host's refHost()
|
||||
// foreign key, as the file-manager, ssh-terminal and
|
||||
// session-recording plugins' adopted tables.
|
||||
// Plugin rows tied to a host cascade on their refHost() foreign keys.
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
|
||||
@@ -80,7 +80,7 @@ export function containsOwnerPrivateAuthUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
export const FOLDER_PATH_SEPARATOR = " / ";
|
||||
const FOLDER_PATH_SEPARATOR = " / ";
|
||||
|
||||
/**
|
||||
* Re-paths a folder string when its ancestor folder is renamed. Returns the new
|
||||
|
||||
@@ -132,7 +132,7 @@ const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||
* Drops the fields a shared host's recipient may not read: a field declares
|
||||
* the lowest share level that sees it with shareRead. The owner sees all.
|
||||
*/
|
||||
export function visibleHostPluginSettings(
|
||||
function visibleHostPluginSettings(
|
||||
values: HostPluginSettings,
|
||||
host: Record<string, unknown>,
|
||||
): HostPluginSettings {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { isQuickConnectAuthType } from "../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
|
||||
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
@@ -44,7 +46,6 @@ import {
|
||||
withHostPluginSettings,
|
||||
} from "./host-plugin-settings.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
||||
@@ -74,9 +75,9 @@ const upload = multer({ storage: multer.memoryStorage() });
|
||||
/**
|
||||
* Tells whoever is polling this host that its details changed.
|
||||
*
|
||||
* An event rather than the HTTP POST to localhost:30005 this used to be: host
|
||||
* metrics is a plugin now and has no port of its own. Fire and forget, so a
|
||||
* subscriber that throws cannot fail the host update that caused it.
|
||||
* An event, so whichever plugin polls the host can listen for it. Fire and
|
||||
* forget, so a subscriber that throws cannot fail the host update that
|
||||
* caused it.
|
||||
*/
|
||||
function notifyStatsHostUpdated(
|
||||
hostId: number,
|
||||
@@ -556,7 +557,7 @@ router.post(
|
||||
* description: SSH username
|
||||
* authType:
|
||||
* type: string
|
||||
* enum: [password, key, credential]
|
||||
* description: Any registered SSH auth type that works for an unsaved host (password, key, credential, agent, none, or one a plugin marks for Quick Connect).
|
||||
* description: Authentication method
|
||||
* password:
|
||||
* type: string
|
||||
@@ -618,6 +619,13 @@ router.post(
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
ensureCoreSshAuthProviders();
|
||||
if (!isQuickConnectAuthType(String(authType))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "That auth type cannot be used for Quick Connect" });
|
||||
}
|
||||
|
||||
try {
|
||||
let resolvedPassword = password;
|
||||
let resolvedKey = key;
|
||||
@@ -2436,8 +2444,6 @@ registerHostBulkRoutes(
|
||||
requireDataAccess,
|
||||
);
|
||||
|
||||
registerHostAuthCompatRoutes(router);
|
||||
|
||||
registerHostNetworkRoutes(router, {
|
||||
authenticateJWT,
|
||||
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||
|
||||
@@ -28,16 +28,6 @@ function getTabTtlMs(): number {
|
||||
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
|
||||
}
|
||||
|
||||
// Legacy tab types that were renamed. Normalize on read so previously saved
|
||||
// tabs still restore to the correct (renamed) tab type.
|
||||
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
|
||||
stats: "host-metrics",
|
||||
};
|
||||
|
||||
function normalizeTabType(tabType: string): string {
|
||||
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
|
||||
}
|
||||
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
@@ -46,9 +36,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId,
|
||||
cutoff,
|
||||
);
|
||||
return res.json(
|
||||
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
|
||||
);
|
||||
return res.json(tabs);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get open tabs", e, {
|
||||
operation: "get_open_tabs",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { getPluginRouter } from "../../plugins/http.js";
|
||||
import { getRequestBasePath } from "../../utils/request-origin.js";
|
||||
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -78,21 +80,44 @@ export function mountPluginApi(app: express.Express): void {
|
||||
app.use("/plugin-api", router);
|
||||
}
|
||||
|
||||
export interface ActiveLegacyRoutes {
|
||||
id: string;
|
||||
legacyPaths: string[];
|
||||
legacyRedirects: PluginLegacyRedirect[];
|
||||
}
|
||||
|
||||
function under(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the old URLs plugins declare in contributes.http.legacyPaths, each
|
||||
* under "/<plugin id>/", through that plugin's router with the prefix
|
||||
* removed. Mounted after every core route, so core wins a clash.
|
||||
* Serves the old URLs plugins declare in contributes.http. A legacyPaths
|
||||
* entry, under "/<plugin id>/", runs through that plugin's router with the
|
||||
* prefix removed. A legacyRedirects entry redirects to the plugin's own URL,
|
||||
* keeping the rest of the path and the query. Mounted after every core route,
|
||||
* so core wins a clash.
|
||||
*/
|
||||
export function mountPluginLegacyPaths(
|
||||
app: express.Express,
|
||||
listActive: () => Array<{ id: string; legacyPaths: string[] }>,
|
||||
listActive: () => ActiveLegacyRoutes[],
|
||||
): void {
|
||||
app.use((req: Request, res: Response, next) => {
|
||||
for (const plugin of listActive()) {
|
||||
const matched = plugin.legacyPaths.some(
|
||||
(path) => req.path === path || req.path.startsWith(`${path}/`),
|
||||
const redirect = plugin.legacyRedirects.find((entry) =>
|
||||
under(req.path, entry.from),
|
||||
);
|
||||
if (!matched) continue;
|
||||
if (redirect) {
|
||||
const rest = req.path.slice(redirect.from.length);
|
||||
const query = req.originalUrl.indexOf("?");
|
||||
res.redirect(
|
||||
redirect.status ?? 307,
|
||||
`${getRequestBasePath(req)}/plugin-api/${plugin.id}${redirect.to}${rest}${
|
||||
query >= 0 ? req.originalUrl.slice(query) : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!plugin.legacyPaths.some((path) => under(req.path, path))) continue;
|
||||
const pluginRouter = getPluginRouter(plugin.id);
|
||||
if (!pluginRouter) {
|
||||
res.status(404).json({ error: "Plugin not installed or not running" });
|
||||
|
||||
@@ -18,12 +18,14 @@ export interface SshAuthProviderSummary {
|
||||
credentialType: boolean;
|
||||
needsUserInteraction: boolean;
|
||||
supportsBackground: boolean;
|
||||
/** Offered in Quick Connect, for a host that is never saved. */
|
||||
quickConnect: boolean;
|
||||
available: boolean;
|
||||
/** Set when the type belongs to a plugin that is disabled or missing. */
|
||||
missingPlugin?: { id: string; name: string };
|
||||
}
|
||||
|
||||
export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
|
||||
function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
|
||||
ensureCoreSshAuthProviders();
|
||||
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
|
||||
(provider) => ({
|
||||
@@ -35,6 +37,7 @@ export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
|
||||
credentialType: !!provider.credentialType,
|
||||
needsUserInteraction: !!provider.needsUserInteraction,
|
||||
supportsBackground: provider.supportsBackground !== false,
|
||||
quickConnect: !!provider.quickConnect,
|
||||
available: true,
|
||||
}),
|
||||
);
|
||||
@@ -50,6 +53,7 @@ export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
|
||||
credentialType: false,
|
||||
needsUserInteraction: false,
|
||||
supportsBackground: false,
|
||||
quickConnect: false,
|
||||
available: false,
|
||||
missingPlugin: { id: owner.pluginId, name: owner.pluginName },
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { sql, type SQLWrapper } from "drizzle-orm";
|
||||
* `substr` are used rather than `datetime()` to keep the expression portable
|
||||
* across engines.
|
||||
*/
|
||||
export const CANONICAL_TIMESTAMP_LENGTH = 19;
|
||||
const CANONICAL_TIMESTAMP_LENGTH = 19;
|
||||
|
||||
export function normalizeSyncTimestamp(value: string): string {
|
||||
return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH);
|
||||
|
||||
Reference in new issue
Block a user