diff --git a/apps/desktop/src/lib/connectionUrl.ts b/apps/desktop/src/lib/connectionUrl.ts index deb700f68..d4e700588 100644 --- a/apps/desktop/src/lib/connectionUrl.ts +++ b/apps/desktop/src/lib/connectionUrl.ts @@ -79,20 +79,71 @@ export function normalizeMongoConnectionString(value: string): string { const input = value.trim(); if (!input) return input; - let parsed: URL; - try { - parsed = new URL(input); - } catch { - return input; + const mongoMatch = input.match(/^(mongodb(?:\+srv)?):\/\/(?:(.+)@)?/i); + if (!mongoMatch) return input; + + const userinfo = mongoMatch[2]; + if (!userinfo) return input; + + const [username, ...passwordParts] = userinfo.split(":"); + const password = passwordParts.join(":"); + const encodedUsername = encodeMongoUserInfoPart(username); + const encodedPassword = password ? `:${encodeMongoUserInfoPart(password)}` : ""; + + return input.replace(/^(mongodb(?:\+srv)?:\/\/)(?:(.+)@)?/i, `$1${encodedUsername}${encodedPassword}@`); +} + +function parseMongoUrl(source: string): ParsedConnectionUrl | null { + const match = source.match(/^(mongodb(?:\+srv)?):\/\/(?:(.+)@)?([^/]+)(\/[^?]*)?(\?.*)?$/); + if (!match) return null; + + const scheme = match[1].toLowerCase(); + const userinfo = match[2] || ""; + const hosts = match[3] || ""; + const pathname = match[4] || ""; + const search = match[5] || ""; + + const profile = SCHEME_PROFILES[scheme]; + if (!profile) return null; + + const [username, ...passwordParts] = decodeUrlPart(userinfo).split(":"); + const password = passwordParts.join(":"); + + const firstHost = hosts.split(",")[0]; + let host: string; + let port: number; + if (firstHost.startsWith("[")) { + const bracketEnd = firstHost.indexOf("]"); + host = firstHost.substring(1, bracketEnd); + port = firstHost.substring(bracketEnd + 1).startsWith(":") + ? Number(firstHost.substring(bracketEnd + 2)) || profile.defaultPort + : profile.defaultPort; + } else if (firstHost.includes(":")) { + const colonIdx = firstHost.lastIndexOf(":"); + host = firstHost.substring(0, colonIdx); + port = Number(firstHost.substring(colonIdx + 1)) || profile.defaultPort; + } else { + host = firstHost; + port = profile.defaultPort; } - const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); - if (scheme !== "mongodb" && scheme !== "mongodb+srv") return input; - if (!parsed.username && !parsed.password) return input; + const database = databaseFromPath(pathname); + const urlParams = search.replace(/^\?/, ""); - const username = encodeMongoUserInfoPart(parsed.username); - const password = parsed.password ? `:${encodeMongoUserInfoPart(parsed.password)}` : ""; - return `${parsed.protocol}//${username}${password}@${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`; + return { + dbType: profile.type, + driverProfile: profile.profile, + driverLabel: profile.label, + host, + port, + username, + password, + database, + urlParams, + ssl: scheme === "mongodb+srv", + connectionString: normalizeMongoConnectionString(source), + useMongoUrl: true, + }; } function databaseFromPath(pathname: string): string | undefined { @@ -330,6 +381,9 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa const isJdbcUrl = /^jdbc:/i.test(input); const source = isJdbcUrl ? input.replace(/^jdbc:/i, "") : input; + const mongoResult = parseMongoUrl(source); + if (mongoResult) return mongoResult; + let parsed: URL; try { parsed = new URL(source); diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 14194464b..a0641f060 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -17,15 +17,35 @@ pub struct MongoDocumentResult { } pub async fn connect(url: &str, timeout: Duration) -> Result { - with_connection_timeout("MongoDB", timeout, async { + let is_multi_host = is_multi_host_mongo_uri(url); + let parse_timeout = if is_multi_host { std::cmp::max(timeout * 2, Duration::from_secs(10)) } else { timeout }; + + with_connection_timeout("MongoDB", parse_timeout, async { let mut options = ClientOptions::parse(url).await.map_err(|e| format!("MongoDB connection failed: {e}"))?; options.connect_timeout = Some(timeout); - options.server_selection_timeout = Some(timeout); + options.server_selection_timeout = + if is_multi_host { Some(std::cmp::max(timeout * 2, Duration::from_secs(10))) } else { Some(timeout) }; Client::with_options(options).map_err(|e| format!("MongoDB connection failed: {e}")) }) .await } +fn is_multi_host_mongo_uri(url: &str) -> bool { + let rest = match url.strip_prefix("mongodb://").or_else(|| url.strip_prefix("mongodb+srv://")) { + Some(r) => r, + None => return false, + }; + let authority = match rest.split('/').next() { + Some(a) => a, + None => return false, + }; + let host_section = match authority.rfind('@') { + Some(idx) => &authority[idx + 1..], + None => authority, + }; + host_section.contains(',') +} + pub async fn test_connection(client: &Client, _timeout: Duration, database: Option<&str>) -> Result<(), String> { let database = database.map(str::trim).filter(|value| !value.is_empty()).unwrap_or("admin"); client diff --git a/packages/app-tests/connectionUrl.test.ts b/packages/app-tests/connectionUrl.test.ts index 694a996a4..151e5bacc 100644 --- a/packages/app-tests/connectionUrl.test.ts +++ b/packages/app-tests/connectionUrl.test.ts @@ -284,6 +284,66 @@ test("parses HTTPS ClickHouse URLs with selected profile", () => { assert.equal(parsed.ssl, true); }); +test("parses MongoDB multi-host replica set URL", () => { + const source = + "mongodb://test:test@1.1.1.1:27017,1.1.1.2:27017,1.1.1.3:27017/admin?authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.dbType, "mongodb"); + assert.equal(parsed.driverProfile, "mongodb"); + assert.equal(parsed.host, "1.1.1.1"); + assert.equal(parsed.port, 27017); + assert.equal(parsed.username, "test"); + assert.equal(parsed.password, "test"); + assert.equal(parsed.database, "admin"); + assert.equal(parsed.urlParams, "authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0"); + assert.equal(parsed.connectionString, source); + assert.equal(parsed.useMongoUrl, true); + assert.equal(parsed.ssl, false); +}); + +test("parses MongoDB single-host URL with replicaSet and auth params", () => { + const source = + "mongodb://test:test@1.1.1.1:27017/?authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.dbType, "mongodb"); + assert.equal(parsed.host, "1.1.1.1"); + assert.equal(parsed.port, 27017); + assert.equal(parsed.username, "test"); + assert.equal(parsed.password, "test"); + assert.equal(parsed.urlParams, "authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0"); + assert.equal(parsed.connectionString, source); + assert.equal(parsed.useMongoUrl, true); +}); + +test("parses MongoDB multi-host URL without credentials", () => { + const source = "mongodb://host1:27017,host2:27017/?replicaSet=rs0"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.dbType, "mongodb"); + assert.equal(parsed.host, "host1"); + assert.equal(parsed.port, 27017); + assert.equal(parsed.username, ""); + assert.equal(parsed.password, ""); + assert.equal(parsed.urlParams, "replicaSet=rs0"); + assert.equal(parsed.connectionString, source); + assert.equal(parsed.useMongoUrl, true); +}); + +test("parses MongoDB URL with simple authSource only", () => { + const source = "mongodb://test:test@1.1.1.1:27017/?authSource=admin"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.dbType, "mongodb"); + assert.equal(parsed.host, "1.1.1.1"); + assert.equal(parsed.port, 27017); + assert.equal(parsed.username, "test"); + assert.equal(parsed.password, "test"); + assert.equal(parsed.urlParams, "authSource=admin"); + assert.equal(parsed.useMongoUrl, true); +}); + test("rejects unsupported URL schemes", () => { assert.throws(() => parseConnectionUrl("ftp://example.com"), /Unsupported connection URL scheme/); });