Files
gridline/desktop/src/lib/connectionString.ts
T
adrianbonpin 3454ce67fe fix(connections): persist SSL settings and honor a URL's sslmode (#36)
Connecting to a managed provider (Neon in the report) failed with
"connection is insecure (try using `sslmode=require`)" because the TLS mode
never reached the backend:

- ConnectionFormData carried no ssl_* fields and both submit handlers
  (NewConnectionScreen.buildPayload, EditConnectionModal save + test) omitted
  them, so the SSH/SSL tab's mode was silently dropped before IPC.
- SslFields wrote ssl_ca_cert / ssl_client_cert / ssl_client_key — names the
  Rust ConnectionInput ignores (it expects ssl_ca_path / ssl_cert_path /
  ssl_key_path).
- parseConnectionString ignored query params, so a pasted Neon/Supabase URL's
  ?sslmode=require was discarded.

Pasting a URI now fills every setting it can (sslmode / ssl-mode, including
MySQL's REQUIRED and VERIFY_IDENTITY spellings) and offers a derived label
(database name, else host/file stem) as a placeholder that Test/Save uses when
the field is left blank. Managed presets pre-select their TLS mode (Supabase /
Neon → Require, PlanetScale → Verify Full).

Backend safety net: db::tls::resolve_ssl_mode defaults *.neon.tech /
*.supabase.co / *.psdb.cloud to `require` when a connection has no ssl_mode, so
connections saved before this fix keep working; an explicit mode (including
disable) always wins.

Tests: +15 frontend (parser, form payload, SSL field names, edit modal),
+4 Rust (resolve_ssl_mode / tls_decision_for).
2026-09-24 03:25:13 +08:00

214 lines
6.9 KiB
TypeScript

import type { Connection, DbType } from "./types";
export interface ParsedConnectionString {
db_type: DbType;
host: string;
port: number | null;
username: string | null;
password: string | null;
database: string | null;
/** SSL mode lifted from the URL's `sslmode` / `ssl-mode` query param, or
* `null` when absent/unsupported. Keeps managed-PostgreSQL URLs (Neon,
* Supabase, …) working — they ship with `?sslmode=require` and the server
* rejects a plaintext connection with "connection is insecure". */
ssl_mode: SslMode | null;
}
/** SSL modes the backend understands (mirrors `DbConfig.ssl_mode`). */
export type SslMode = "disable" | "require" | "verify-ca" | "verify-full";
/** Alternate spellings used by other clients, mapped onto our four modes:
* MySQL's `ssl-mode` vocabulary (`REQUIRED`, `VERIFY_IDENTITY`, …) and
* plural forms. `allow` / `prefer` are intentionally absent — they permit a
* plaintext fallback, which our mode set cannot express, so they return
* `null` and the caller keeps its current/default mode. */
const SSL_MODE_ALIASES: Record<string, SslMode> = {
disable: "disable",
disabled: "disable",
require: "require",
required: "require",
"verify-ca": "verify-ca",
"verify-full": "verify-full",
// MySQL's strongest mode: chain + hostname verification.
"verify-identity": "verify-full",
};
/**
* Extract the TLS mode from a connection URL's query string.
*
* Accepts libpq's `sslmode` (PostgreSQL) and MySQL's `ssl-mode` spelling,
* case-insensitively and hyphen/underscore-insensitively (`verify-full`,
* `verify_full`, `VERIFY-FULL`, …). Unsupported values (`allow`, `prefer`)
* return `null` so the caller keeps its current/default mode rather than
* silently downgrading to plaintext.
*/
function parseSslMode(url: URL): SslMode | null {
let raw: string | null = null;
for (const [key, value] of url.searchParams) {
const k = key.toLowerCase();
if (k === "sslmode" || k === "ssl-mode") {
raw = value;
break;
}
}
if (!raw) return null;
const normalized = raw.trim().toLowerCase().replace(/_/g, "-");
return SSL_MODE_ALIASES[normalized] ?? null;
}
const DB_PROTOCOLS: Record<string, DbType> = {
"postgresql:": "postgresql",
"postgres:": "postgresql",
"mysql:": "mysql",
"mariadb:": "mariadb",
"sqlite:": "sqlite",
"file:": "sqlite",
"redis:": "redis",
"rediss:": "redis",
};
const DEFAULT_PORTS: Record<DbType, number | null> = {
postgresql: 5432,
mysql: 3306,
mariadb: 3306,
sqlite: null,
redis: 6379,
};
export function parseConnectionString(input: string): ParsedConnectionString | null {
const trimmed = input.trim();
if (!trimmed) return null;
let url: URL;
try {
url = new URL(trimmed);
} catch {
return null;
}
const db_type = DB_PROTOCOLS[url.protocol];
if (!db_type) return null;
const port = url.port ? Number(url.port) : (DEFAULT_PORTS[db_type] ?? null);
const username = url.username || null;
const password = url.password || null;
const pathname = url.pathname;
let host: string;
let database: string | null;
if (db_type === "sqlite") {
// SQLite: the file path lives in the URL pathname. Map it to `host`
// (the field the backend opens) and leave `database` null. `url.pathname`
// always starts with "/"; the fallback covers `sqlite://path` (no
// pathname) where the URL's hostname holds the relative path.
host = pathname || url.hostname || "";
database = null;
} else {
host = url.hostname || "localhost";
database = pathname.replace(/^\//, "") || null;
}
return {
db_type,
host,
port,
username,
password,
database,
ssl_mode: db_type === "postgresql" || db_type === "mysql" || db_type === "mariadb"
? parseSslMode(url)
: null,
};
}
export function looksLikeConnectionString(input: string): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
try {
const url = new URL(trimmed);
return !!DB_PROTOCOLS[url.protocol];
} catch {
return false;
}
}
/** Detect a managed-PostgreSQL provider from a connection host, purely for
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
* unaffected (both presets persist as `postgresql`). */
export function detectProviderFromHost(
host: string,
): "supabase" | "neon" | "planetscale" | null {
const h = host.toLowerCase();
if (h.endsWith(".supabase.co")) return "supabase";
if (h.endsWith(".neon.tech")) return "neon";
if (h.endsWith(".psdb.cloud")) return "planetscale";
return null;
}
/**
* Suggest a connection label for a freshly pasted connection string, so the
* connection can be saved without typing a name first.
*
* Preference order: the database name, then (for SQLite) the file stem, then
* the host. Capped at 100 chars to satisfy `validateConnectionInput`, which
* rejects longer names.
*/
export function suggestConnectionLabel(parsed: ParsedConnectionString): string {
let label: string;
if (parsed.db_type === "sqlite") {
const file = parsed.host.split(/[\\/]/).pop() ?? "";
label = file.replace(/\.(sqlite3?|db)$/i, "") || file;
} else {
label = parsed.database?.replace(/^\/+/, "") || parsed.host;
}
return label.trim().slice(0, 100);
}
/**
* Build a connection URL string from a saved connection, for copying into
* env vars / other tools. `password` is the decrypted keychain value (or
* `null` to omit it — e.g. the "no password" copy variant).
*
* Formats per DB type:
* - postgresql: `postgresql://user:pass@host:port/db?sslmode=...`
* - mysql: `mysql://user:pass@host:port/db`
* - sqlite: `sqlite:///abs/path` (host stores the file path)
* - redis: `redis://user:pass@host:port/db`
*
* Username/password/database are percent-encoded; the ssl_mode is appended as
* a query param for PostgreSQL when set to anything other than `disable`.
*/
export function buildConnectionUrl(conn: Connection, password: string | null): string {
const enc = (s: string) => encodeURIComponent(s);
const port = conn.port ? `:${conn.port}` : "";
const user = conn.username ? enc(conn.username) : "";
const auth = password
? user
? `${user}:${enc(password)}@`
: `:${enc(password)}@`
: user
? `${user}@`
: "";
const db = conn.database ? `/${enc(conn.database)}` : "";
switch (conn.db_type) {
case "postgresql": {
const ssl =
conn.ssl_mode && conn.ssl_mode !== "disable"
? `?sslmode=${encodeURIComponent(conn.ssl_mode)}`
: "";
return `postgresql://${auth}${conn.host}${port}${db}${ssl}`;
}
case "mysql":
return `mysql://${auth}${conn.host}${port}${db}`;
case "mariadb":
return `mariadb://${auth}${conn.host}${port}${db}`;
case "sqlite":
// host stores the file path; `sqlite:///abs/path` round-trips through
// parseConnectionString (pathname → host).
return `sqlite://${conn.host}`;
case "redis":
return `redis://${auth}${conn.host}${port}${db}`;
}
}