Support PostgreSQL multi-host connection URIs in DATABASE_URL (#10754)

* Initial plan

* Add isDatabaseUrl validator for multi-host PostgreSQL URIs

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Update env.ts

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Copilot
2025-12-01 08:12:39 -05:00
committed by GitHub
parent 8546a2bada
commit d75f8d64db
3 changed files with 395 additions and 15 deletions
+4 -15
View File
@@ -22,6 +22,7 @@ import {
CannotUseWithout,
CannotUseWithAny,
IsInCaseInsensitive,
IsDatabaseUrl,
} from "@server/utils/validators";
import Deprecated from "./models/decorators/Deprecated";
import { getArg } from "./utils/args";
@@ -80,11 +81,7 @@ export class Environment {
* The url of the database.
*/
@IsOptional()
@IsUrl({
require_tld: false,
allow_underscores: true,
protocols: ["postgres", "postgresql"],
})
@IsDatabaseUrl()
@CannotUseWithAny([
"DATABASE_HOST",
"DATABASE_PORT",
@@ -99,11 +96,7 @@ export class Environment {
* and reduce load on primary database.
*/
@IsOptional()
@IsUrl({
require_tld: false,
allow_underscores: true,
protocols: ["postgres", "postgresql"],
})
@IsDatabaseUrl()
public DATABASE_URL_READ_ONLY = this.toOptionalString(
environment.DATABASE_URL_READ_ONLY
);
@@ -156,11 +149,7 @@ export class Environment {
* The url of the database pool.
*/
@IsOptional()
@IsUrl({
require_tld: false,
allow_underscores: true,
protocols: ["postgres", "postgresql"],
})
@IsDatabaseUrl()
public DATABASE_CONNECTION_POOL_URL = this.toOptionalString(
environment.DATABASE_CONNECTION_POOL_URL
);
+245
View File
@@ -0,0 +1,245 @@
import { describe, it, expect } from "@jest/globals";
import { isDatabaseUrl } from "./validators";
describe("isDatabaseUrl", () => {
const defaultOptions = {
protocols: ["postgres", "postgresql"],
require_tld: false,
allow_underscores: true,
};
describe("single host URLs", () => {
it("should accept a valid postgresql URL with single host", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@localhost:5432/database",
defaultOptions
)
).toBe(true);
});
it("should accept a valid postgres URL with single host", () => {
expect(
isDatabaseUrl(
"postgres://user:password@localhost:5432/database",
defaultOptions
)
).toBe(true);
});
it("should accept URL without port", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@localhost/database",
defaultOptions
)
).toBe(true);
});
it("should accept URL without database name", () => {
expect(
isDatabaseUrl("postgresql://user:password@localhost:5432", defaultOptions)
).toBe(true);
});
it("should accept URL with query parameters", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@localhost:5432/database?sslmode=require",
defaultOptions
)
).toBe(true);
});
it("should accept URL with hostname containing underscores when allowed", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@my_host:5432/database",
defaultOptions
)
).toBe(true);
});
it("should accept URL with hostname containing hyphens", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@my-host:5432/database",
defaultOptions
)
).toBe(true);
});
});
describe("multi-host URLs", () => {
it("should accept multi-host URL with ports", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@node1.pg18:5432,node2.pg18:5432,node3.pg18:5432,node4.pg18:5432/database",
defaultOptions
)
).toBe(true);
});
it("should accept multi-host URL without ports", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@node1.pg18,node2.pg18,node3.pg18,node4.pg18/database",
defaultOptions
)
).toBe(true);
});
it("should accept multi-host URL with query parameters", () => {
expect(
isDatabaseUrl(
"postgresql://wiki_user:password@node1.pg18,node2.pg18,node3.pg18,node4.pg18/wiki?target_session_attrs=read-write",
defaultOptions
)
).toBe(true);
});
it("should accept multi-host URL with mixed port specifications", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@host1:5432,host2,host3:5433/database",
defaultOptions
)
).toBe(true);
});
it("should accept multi-host URL with two hosts", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@primary.db,replica.db/mydb",
defaultOptions
)
).toBe(true);
});
it("should accept multi-host URL without auth", () => {
expect(
isDatabaseUrl(
"postgresql://host1:5432,host2:5432/database",
defaultOptions
)
).toBe(true);
});
});
describe("invalid URLs", () => {
it("should reject empty string", () => {
expect(isDatabaseUrl("", defaultOptions)).toBe(false);
});
it("should reject null", () => {
expect(isDatabaseUrl(null as any, defaultOptions)).toBe(false);
});
it("should reject undefined", () => {
expect(isDatabaseUrl(undefined as any, defaultOptions)).toBe(false);
});
it("should reject URL with invalid protocol", () => {
expect(
isDatabaseUrl("mysql://user:password@localhost/database", defaultOptions)
).toBe(false);
});
it("should reject URL without protocol", () => {
expect(
isDatabaseUrl("user:password@localhost/database", defaultOptions)
).toBe(false);
});
it("should reject URL with empty host", () => {
expect(isDatabaseUrl("postgresql:///database", defaultOptions)).toBe(
false
);
});
it("should reject URL with invalid port", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@localhost:99999/database",
defaultOptions
)
).toBe(false);
});
it("should reject URL with invalid port (non-numeric)", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@localhost:abc/database",
defaultOptions
)
).toBe(false);
});
it("should reject multi-host URL with empty host", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@host1,,host2/database",
defaultOptions
)
).toBe(false);
});
it("should reject URL with underscores when not allowed", () => {
expect(
isDatabaseUrl(
"postgresql://user:password@my_host:5432/database",
{ ...defaultOptions, allow_underscores: false }
)
).toBe(false);
});
});
describe("protocol validation", () => {
it("should reject URL when protocol not in allowed list", () => {
expect(
isDatabaseUrl("postgresql://localhost/db", {
...defaultOptions,
protocols: ["postgres"],
})
).toBe(false);
});
it("should accept URL when protocol in custom allowed list", () => {
expect(
isDatabaseUrl("postgres://localhost/db", {
...defaultOptions,
protocols: ["postgres"],
})
).toBe(true);
});
});
describe("TLD requirement", () => {
it("should accept hostname with TLD when required", () => {
expect(
isDatabaseUrl("postgresql://user:password@host.example.com/database", {
...defaultOptions,
require_tld: true,
})
).toBe(true);
});
it("should reject hostname without TLD when required", () => {
expect(
isDatabaseUrl("postgresql://user:password@localhost/database", {
...defaultOptions,
require_tld: true,
})
).toBe(false);
});
it("should accept hostname without TLD when not required", () => {
expect(
isDatabaseUrl("postgresql://user:password@localhost/database", {
...defaultOptions,
require_tld: false,
})
).toBe(true);
});
});
});
+146
View File
@@ -4,6 +4,110 @@ import {
ValidationOptions,
} from "class-validator";
/**
* Validates a PostgreSQL database connection URL, including support for
* multi-host connection strings as documented in:
* https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS
*
* Supports:
* - Single host: postgresql://user:pass@host:port/db
* - Multi-host: postgresql://user:pass@host1:port1,host2:port2,host3:port3/db
* - With query parameters: postgresql://user:pass@host1,host2/db?param=value
*
* @param url the database URL to validate.
* @param protocols the protocols to allow (e.g., ["postgres", "postgresql"]).
* @param requireTld whether to require top-level domain in hostnames.
* @param allowUnderscores whether to allow underscores in hostnames.
* @returns true if the URL is valid, false otherwise.
*/
export function isDatabaseUrl(
url: string,
options: {
protocols?: string[];
require_tld?: boolean;
allow_underscores?: boolean;
} = {}
): boolean {
const {
protocols = ["postgres", "postgresql"],
require_tld = false,
allow_underscores = true,
} = options;
if (!url || typeof url !== "string") {
return false;
}
try {
// Check if protocol is valid
const protocolMatch = url.match(/^(\w+):\/\//);
if (!protocolMatch || !protocols.includes(protocolMatch[1])) {
return false;
}
// Extract the URL components
// Format: protocol://[user[:password]@]host1[:port1][,host2[:port2],...][/database][?params]
const protocolEnd = url.indexOf("://") + 3;
const urlWithoutProtocol = url.substring(protocolEnd);
// Split by @ to separate auth from host/path
const atIndex = urlWithoutProtocol.lastIndexOf("@");
const hasAuth = atIndex !== -1;
const hostAndPath = hasAuth
? urlWithoutProtocol.substring(atIndex + 1)
: urlWithoutProtocol;
// Split host section from path/query
const pathStart = hostAndPath.search(/[/?]/);
const hostSection =
pathStart === -1 ? hostAndPath : hostAndPath.substring(0, pathStart);
if (!hostSection) {
return false;
}
// Split multiple hosts by comma
const hosts = hostSection.split(",");
// Validate each host
for (const hostWithPort of hosts) {
const host = hostWithPort.split(":")[0];
if (!host) {
return false;
}
// Check for invalid characters in hostname
const hostnameRegex = allow_underscores
? /^[a-zA-Z0-9._-]+$/
: /^[a-zA-Z0-9.-]+$/;
if (!hostnameRegex.test(host)) {
return false;
}
// Check TLD requirement if specified
if (require_tld && !host.includes(".")) {
return false;
}
// Validate port if present
const colonIndex = hostWithPort.indexOf(":");
if (colonIndex !== -1) {
const portStr = hostWithPort.substring(colonIndex + 1);
const port = parseInt(portStr, 10);
if (isNaN(port) || port < 1 || port > 65535) {
return false;
}
}
}
return true;
} catch {
return false;
}
}
export function CannotUseWithout(
property: string,
validationOptions?: ValidationOptions
@@ -121,3 +225,45 @@ export function IsInCaseInsensitive(
});
};
}
/**
* Decorator that validates PostgreSQL database connection URLs, including
* multi-host connection strings for high-availability setups.
*
* @param options validation options including protocols, require_tld, and allow_underscores.
* @param validationOptions additional validation options.
* @returns decorator function.
*/
export function IsDatabaseUrl(
options: {
protocols?: string[];
require_tld?: boolean;
allow_underscores?: boolean;
} = {},
validationOptions?: ValidationOptions
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: "isDatabaseUrl",
target: object.constructor,
propertyName,
constraints: [options],
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
if (value === undefined || value === null) {
return true;
}
if (typeof value !== "string") {
return false;
}
const opts = args.constraints[0] as typeof options;
return isDatabaseUrl(value, opts);
},
defaultMessage() {
return `${propertyName} must be a URL address`;
},
},
});
};
}