mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
+4
-15
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user