feat(connection): update saved connections by ID through deep links

This commit is contained in:
Klinge authored and GitHub committed 2026-09-21 01:41:44 +08:00
1 parent 221683950d
commit d66242508e
10 files changed
+818 -13

No files matched your search

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { parseConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink";
import { applyConnectionDeepLinkUpdate, resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate";
import type { ConnectionConfig } from "@/types/database";
const saved: ConnectionConfig = {
id: "saved-id",
name: "Shared name",
db_type: "postgres",
driver_profile: "postgres",
host: "database.example.test",
port: 15432,
username: "old-user",
password: "old-password",
database: "orders",
ssl: true,
read_only: true,
is_production: true,
production_databases: ["orders"],
visible_databases: ["orders"],
ca_cert_path: "/certs/custom-ca.pem",
save_password: false,
query_timeout_secs: 45,
};
describe("saved connection update links", () => {
it("selects the exact saved ID, even when another connection has the same name", () => {
const other = { ...saved, id: "other-id" };
const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&password=new")!;
expect(resolveConnectionDeepLinkUpdate(update, [other, saved], false)).toBe(saved);
});
it("rejects missing or temporary targets without selecting a same-name fallback", () => {
const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=missing&name=Shared+name")!;
expect(() => resolveConnectionDeepLinkUpdate(update, [saved], false)).toThrow(/not found/);
expect(() => resolveConnectionDeepLinkUpdate({ ...update, connectionId: saved.id }, [{ ...saved, one_time: true }], false)).toThrow(/saved connection/);
});
it("refuses to replace an open editor or creation draft", () => {
const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&password=new")!;
expect(() => resolveConnectionDeepLinkUpdate(update, [saved], true)).toThrow(/current connection dialog/);
});
it.each([{ db_type: "mongodb", driver_profile: "mongodb" }, { db_type: "mysql", driver_profile: "tidb" }, { connection_string: "postgres://original.example.test/orders" }] as Partial<ConnectionConfig>[])("rejects a target with unsupported configuration %j", (overrides) => {
const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&password=new")!;
const config = { ...saved, ...overrides };
expect(() => resolveConnectionDeepLinkUpdate(update, [config], false)).toThrow(/field-based/);
expect(() => applyConnectionDeepLinkUpdate(config, update)).toThrow(/field-based/);
});
it("patches credentials without changing IDs, endpoint, SSL, policy or unsupplied fields", () => {
const original = structuredClone(saved);
const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&user=+new-user+&password=%20token%2B%26%3D%20")!;
const result = applyConnectionDeepLinkUpdate(Object.freeze(original), update);
expect(result).toEqual({ ...saved, username: " new-user ", password: " token+&= " });
expect(original).toEqual(saved);
expect(result).not.toBe(original);
});
it("honors explicit clears and false values without applying create defaults", () => {
const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id&database=&password=&url_params=&ssl=false")!;
expect(applyConnectionDeepLinkUpdate({ ...saved, url_params: "old-options" }, update)).toEqual({ ...saved, database: "", password: "", url_params: "", ssl: false });
});
it("an ID-only link leaves the entire saved configuration intact", () => {
const update = parseConnectionDeepLinkUpdate("dbx://connection/new?id=saved-id")!;
expect(applyConnectionDeepLinkUpdate(saved, update)).toEqual(saved);
});
it("rejects oversized parameter values while accepting values at the bound", () => {
expect(parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-id&name=${"n".repeat(4096)}`)!.patch.name).toBe("n".repeat(4096));
for (const key of ["name", "host", "password", "url_params"]) {
expect(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-id&${key}=${"x".repeat(4097)}`)).toThrow(`Connection update ${key} is too long`);
}
});
it("rejects an oversized update URL without rejecting other long deep links", () => {
expect(() => parseConnectionDeepLinkUpdate(`dbx://connection/new?id=saved-id&url_params=${"k=v&".repeat(5000)}`)).toThrow("Connection update URL is too long");
expect(parseConnectionDeepLinkUpdate(`dbx://query/open?sql=${"x".repeat(20000)}`)).toBe(null);
});
});
@@ -32,6 +32,22 @@ export interface ConnectionDeepLinkDraft {
serviceConfig?: ConnectionDeepLinkServiceConfig;
}
export interface ConnectionDeepLinkUpdatePatch {
name?: string;
host?: string;
port?: number;
username?: string;
password?: string;
database?: string;
urlParams?: string;
ssl?: boolean;
}
export interface ConnectionDeepLinkUpdate {
connectionId: string;
patch: ConnectionDeepLinkUpdatePatch;
}
export function connectionDeepLinkServiceHydrationValue(config: ConnectionDeepLinkServiceConfig): Record<string, unknown> {
if (config.kind === "consul") return { serverAddr: config.serverAddr };
if (config.profile === "rnacos") {
@@ -51,11 +67,68 @@ export function connectionDeepLinkServiceHydrationValue(config: ConnectionDeepLi
}
const CONNECTION_DEEP_LINK_TARGET = "connection/new";
const CONNECTION_UPDATE_PARAMS = new Set(["id", "v", "name", "host", "port", "user", "password", "database", "url_params", "ssl"]);
// Untrusted pages build these URLs; cap them so a deep link cannot prefill
// unbounded strings into a saved connection's fields.
const CONNECTION_UPDATE_MAX_URL_LENGTH = 16384;
const CONNECTION_UPDATE_MAX_PARAM_LENGTH = 4096;
function normalizePath(url: URL): string {
return [url.hostname, url.pathname.replace(/^\/+/, "")].filter(Boolean).join("/").replace(/\/+$/, "");
}
/** Parse an explicit-field update without applying the defaults used by new connections. */
export function parseConnectionDeepLinkUpdate(value: string): ConnectionDeepLinkUpdate | null {
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== "dbx:" || normalizePath(url) !== CONNECTION_DEEP_LINK_TARGET || !url.searchParams.has("id")) return null;
if (value.length > CONNECTION_UPDATE_MAX_URL_LENGTH) throw new Error("Connection update URL is too long");
if (url.username || url.password || url.port || url.hash) throw new Error("Invalid connection update URL");
const params = url.searchParams;
for (const key of params.keys()) {
if (!CONNECTION_UPDATE_PARAMS.has(key)) throw new Error("Unsupported connection update parameter");
if (params.getAll(key).length !== 1) throw new Error("Duplicate connection update parameter");
if (params.get(key)!.length > CONNECTION_UPDATE_MAX_PARAM_LENGTH) throw new Error(`Connection update ${key} is too long`);
}
if (params.has("v") && params.get("v")?.trim() !== "1") throw new Error("Unsupported connection update version");
const rawConnectionId = params.get("id")!;
const connectionId = rawConnectionId.trim();
if (!connectionId || /[\u0000-\u001f\u007f]/.test(rawConnectionId)) throw new Error("A valid connection ID is required for updates");
const patch: ConnectionDeepLinkUpdatePatch = {};
for (const key of ["name", "host"] as const) {
if (!params.has(key)) continue;
const text = params.get(key)!.trim();
if (!text) throw new Error(`Connection update ${key} cannot be empty`);
patch[key] = text;
}
if (params.has("port")) {
const port = params.get("port")!.trim();
const number = Number(port);
if (!/^\d+$/.test(port) || !Number.isInteger(number) || number < 1 || number > 65535) throw new Error("Invalid connection update port");
patch.port = number;
}
// Presence matters: an empty value explicitly clears a field, while omission preserves it.
// Do not trim credentials; spaces can be part of a username or password.
if (params.has("user")) patch.username = params.get("user")!;
if (params.has("password")) patch.password = params.get("password")!;
if (params.has("database")) patch.database = params.get("database")!;
if (params.has("url_params")) patch.urlParams = params.get("url_params")!;
if (params.has("ssl")) {
const ssl = params.get("ssl")!.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(ssl)) patch.ssl = true;
else if (["false", "0", "no", "off"].includes(ssl)) patch.ssl = false;
else throw new Error("Invalid connection update SSL value");
}
return { connectionId, patch };
}
function optionalParam(params: URLSearchParams, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = params.get(key)?.trim();
@@ -209,6 +282,7 @@ export function parseConnectionDeepLink(value: string): ConnectionDeepLinkDraft
if (normalizePath(url) !== CONNECTION_DEEP_LINK_TARGET) return null;
const params = url.searchParams;
if (params.has("id")) throw new Error("Connection update link cannot create a new connection");
validateProtocolVersion(params);
const preferredProfile = optionalParam(params, "type")?.toLowerCase();
const rawConnectionUrl = optionalParam(params, "url");
@@ -0,0 +1,35 @@
import type { ConnectionConfig } from "@/types/database";
import type { ConnectionDeepLinkUpdate } from "./connectionDeepLink";
function validateUpdateConfig(config: Omit<ConnectionConfig, "id">) {
if (!["mysql", "postgres", "sqlserver"].includes(config.db_type) || (config.driver_profile && config.driver_profile !== config.db_type) || config.connection_string?.trim()) {
throw new Error("Update links support built-in MySQL, PostgreSQL and SQL Server field-based connections only.");
}
}
/** Resolve only a saved connection. An invalid update must never become a create. */
export function resolveConnectionDeepLinkUpdate(update: ConnectionDeepLinkUpdate, connections: readonly ConnectionConfig[], dialogOpen: boolean): ConnectionConfig {
if (dialogOpen) throw new Error("Close the current connection dialog before opening an update link.");
const config = connections.find((connection) => connection.id === update.connectionId);
if (!config) throw new Error("The connection specified by the update link was not found.");
if (config.one_time) throw new Error("Update links require a saved connection, not a one-time connection.");
validateUpdateConfig(config);
return config;
}
/** Apply explicit URL fields to a detached draft, leaving saved data untouched. */
export function applyConnectionDeepLinkUpdate<T extends Omit<ConnectionConfig, "id">>(config: T, update: ConnectionDeepLinkUpdate): T {
validateUpdateConfig(config);
const { urlParams, ...fields } = update.patch;
const result = {
...config,
...fields,
...(urlParams !== undefined ? { url_params: urlParams } : {}),
};
if (config.db_type === "sqlserver" && update.patch.port !== undefined) {
const external = config.external_config && typeof config.external_config === "object" && !Array.isArray(config.external_config) ? { ...config.external_config } : {};
delete (external as Record<string, unknown>).port_explicit;
result.external_config = { ...external, portExplicit: true };
}
return result;
}