mirror of
https://github.com/outline/outline.git
synced 2025-12-30 07:19:52 -06:00
267 lines
7.7 KiB
TypeScript
267 lines
7.7 KiB
TypeScript
import type { ValidationArguments, ValidationOptions } from "class-validator";
|
|
import { registerDecorator } 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
|
|
) {
|
|
return function (object: object, propertyName: string) {
|
|
registerDecorator({
|
|
name: "cannotUseWithout",
|
|
target: object.constructor,
|
|
propertyName,
|
|
constraints: [property],
|
|
options: validationOptions,
|
|
validator: {
|
|
validate<T>(value: T, args: ValidationArguments) {
|
|
const obj = args.object as unknown as T;
|
|
const required = args.constraints[0] as keyof T;
|
|
return obj[required] !== undefined;
|
|
},
|
|
defaultMessage(args: ValidationArguments) {
|
|
return `${propertyName} cannot be used without ${args.constraints[0]}.`;
|
|
},
|
|
},
|
|
});
|
|
};
|
|
}
|
|
|
|
export function CannotUseWith(
|
|
property: string,
|
|
validationOptions?: ValidationOptions
|
|
) {
|
|
return function (object: object, propertyName: string) {
|
|
registerDecorator({
|
|
name: "cannotUseWith",
|
|
target: object.constructor,
|
|
propertyName,
|
|
constraints: [property],
|
|
options: validationOptions,
|
|
validator: {
|
|
validate<T>(value: T, args: ValidationArguments) {
|
|
if (value === undefined) {
|
|
return true;
|
|
}
|
|
const obj = args.object as unknown as T;
|
|
const forbidden = args.constraints[0] as keyof T;
|
|
return obj[forbidden] === undefined;
|
|
},
|
|
defaultMessage(args: ValidationArguments) {
|
|
return `${propertyName} cannot be used with ${args.constraints[0]}.`;
|
|
},
|
|
},
|
|
});
|
|
};
|
|
}
|
|
|
|
export function CannotUseWithAny(
|
|
properties: string[],
|
|
validationOptions?: ValidationOptions
|
|
) {
|
|
return function (object: object, propertyName: string) {
|
|
registerDecorator({
|
|
name: "cannotUseWithAny",
|
|
target: object.constructor,
|
|
propertyName,
|
|
constraints: properties,
|
|
options: validationOptions,
|
|
validator: {
|
|
validate<T>(value: T, args: ValidationArguments) {
|
|
if (value === undefined) {
|
|
return true;
|
|
}
|
|
const obj = args.object as unknown as T;
|
|
const forbiddenProperties = args.constraints as (keyof T)[];
|
|
return forbiddenProperties.every((prop) => obj[prop] === undefined);
|
|
},
|
|
defaultMessage(args: ValidationArguments) {
|
|
return `${propertyName} cannot be used with any of: ${args.constraints.join(
|
|
", "
|
|
)}.`;
|
|
},
|
|
},
|
|
});
|
|
};
|
|
}
|
|
|
|
export function IsInCaseInsensitive(
|
|
allowedValues: string[],
|
|
validationOptions?: ValidationOptions
|
|
) {
|
|
return function (object: object, propertyName: string) {
|
|
registerDecorator({
|
|
name: "isInCaseInsensitive",
|
|
target: object.constructor,
|
|
propertyName,
|
|
constraints: [allowedValues],
|
|
options: validationOptions,
|
|
validator: {
|
|
validate<T>(value: T, args: ValidationArguments) {
|
|
if (value === undefined || value === null) {
|
|
return true;
|
|
}
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
const av = args.constraints[0] as string[];
|
|
return av.some(
|
|
(allowedValue) => allowedValue.toLowerCase() === value.toLowerCase()
|
|
);
|
|
},
|
|
defaultMessage(args: ValidationArguments) {
|
|
const av = args.constraints[0] as string[];
|
|
return `${propertyName} must be one of: ${av.join(
|
|
", "
|
|
)} (case insensitive).`;
|
|
},
|
|
},
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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`;
|
|
},
|
|
},
|
|
});
|
|
};
|
|
}
|