first commit
This commit is contained in:
592
ui/lib/utils/validation.ts
Normal file
592
ui/lib/utils/validation.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import { PROVIDER_SUPPORTED_REQUESTS } from "../constants/config";
|
||||
import { BaseProvider } from "../types/config";
|
||||
|
||||
export interface ValidationRule {
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationConfig {
|
||||
rules: ValidationRule[];
|
||||
showAlways?: boolean; // If true, shows tooltip even when field is untouched
|
||||
}
|
||||
|
||||
export interface FieldValidation {
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
showTooltip: boolean;
|
||||
}
|
||||
|
||||
export const validateField = (value: any, config: ValidationConfig, touched: boolean): FieldValidation => {
|
||||
const invalidRule = config.rules.find((rule) => !rule.isValid);
|
||||
|
||||
return {
|
||||
isValid: !invalidRule,
|
||||
message: invalidRule?.message || "",
|
||||
showTooltip: config.showAlways || (touched && !!invalidRule),
|
||||
};
|
||||
};
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const validateForm = (rules: ValidationRule[]): ValidationResult => {
|
||||
const invalidRules = rules.filter((rule) => !rule.isValid);
|
||||
return {
|
||||
isValid: invalidRules.length === 0,
|
||||
errors: invalidRules.map((rule) => rule.message),
|
||||
};
|
||||
};
|
||||
|
||||
export class Validator {
|
||||
private rules: ValidationRule[];
|
||||
|
||||
constructor(rules: ValidationRule[]) {
|
||||
this.rules = rules.filter((rule) => rule !== undefined);
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
return !this.rules.some((rule) => !rule.isValid);
|
||||
}
|
||||
|
||||
getErrors(): string[] {
|
||||
return this.rules.filter((rule) => !rule.isValid).map((rule) => rule.message);
|
||||
}
|
||||
|
||||
getFirstError(): string | undefined {
|
||||
const firstInvalidRule = this.rules.find((rule) => !rule.isValid);
|
||||
return firstInvalidRule?.message;
|
||||
}
|
||||
|
||||
// Built-in validators
|
||||
static required(value: any, message = "This field is required"): ValidationRule {
|
||||
return {
|
||||
isValid: value !== undefined && value !== null && value !== "" && value !== 0,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static minValue(value: number, min: number, message = `Must be at least ${min}`): ValidationRule {
|
||||
return {
|
||||
isValid: !isNaN(value) && value >= min,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static maxValue(value: number, max: number, message = `Must be at most ${max}`): ValidationRule {
|
||||
return {
|
||||
isValid: !isNaN(value) && value <= max,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static pattern(value: string, regex: RegExp, message: string): ValidationRule {
|
||||
return {
|
||||
isValid: regex.test(value || ""),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static email(value: string, message = "Must be a valid email"): ValidationRule {
|
||||
return this.pattern(value, /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message);
|
||||
}
|
||||
|
||||
static url(value: string, message = "Must be a valid URL"): ValidationRule {
|
||||
return this.pattern(value, /^https?:\/\/.+/, message);
|
||||
}
|
||||
|
||||
static minLength(value: string, min: number, message = `Must be at least ${min} characters`): ValidationRule {
|
||||
return {
|
||||
isValid: (value || "").length >= min,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static maxLength(value: string, max: number, message = `Must be at most ${max} characters`): ValidationRule {
|
||||
return {
|
||||
isValid: (value || "").length <= max,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arrayMinLength<T>(array: T[], min: number, message = `Must have at least ${min} items`): ValidationRule {
|
||||
return {
|
||||
isValid: array?.length >= min,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arrayMaxLength<T>(array: T[], max: number, message = `Must have at most ${max} items`): ValidationRule {
|
||||
return {
|
||||
isValid: array?.length <= max,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arrayUnique<T>(array: T[], message = "Must have unique items"): ValidationRule {
|
||||
return {
|
||||
isValid: array?.length === new Set(array).size,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static arraysEqual<T>(array1: T[], array2: T[], message = "Must be equal"): ValidationRule {
|
||||
return {
|
||||
isValid: array1?.length === array2?.length && array1?.every((value, index) => value === array2[index]),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
static custom(isValid: boolean, message: string): ValidationRule {
|
||||
return {
|
||||
isValid,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Combine multiple validation rules
|
||||
static all(rules: ValidationRule[]): ValidationRule {
|
||||
const invalidRule = rules.find((rule) => !rule.isValid);
|
||||
return invalidRule || { isValid: true, message: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions for validation and redaction detection
|
||||
|
||||
/**
|
||||
* Checks if a value is redacted based on the backend redaction patterns
|
||||
* @param value - The value to check
|
||||
* @returns true if the value is redacted
|
||||
*/
|
||||
export function isRedacted(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's an environment variable reference
|
||||
if (value.startsWith("env.")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for exact redaction pattern: 4 chars + 24 asterisks + 4 chars (total 32)
|
||||
if (value.length === 32) {
|
||||
const middle = value.substring(4, 28);
|
||||
if (middle === "*".repeat(24)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for short key redaction (all asterisks, length <= 8)
|
||||
if (value.length <= 8 && /^\*+$/.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a JSON string is valid
|
||||
* @param value - The JSON string to validate
|
||||
* @returns true if valid JSON
|
||||
*/
|
||||
export function isValidJSON(value: string): boolean {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Vertex auth credentials
|
||||
* @param value - The auth credentials value
|
||||
* @returns true if valid (redacted, env var, or valid service account JSON)
|
||||
*/
|
||||
export function isValidVertexAuthCredentials(value: string): boolean {
|
||||
if (!value || !value.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If redacted, consider it valid (backend has the real value)
|
||||
if (isRedacted(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If environment variable, validate format
|
||||
if (value.startsWith("env.")) {
|
||||
return value.length > 4;
|
||||
}
|
||||
|
||||
// Try to parse as service account JSON
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null && parsed.type === "service_account" && parsed.project_id && parsed.private_key;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates aliases configuration
|
||||
* @param value - The aliases value (object or string)
|
||||
* @returns true if valid (redacted, or valid JSON object)
|
||||
*/
|
||||
export function isValidAliases(value: Record<string, string> | string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's already an object, check if it has entries
|
||||
if (typeof value === "object") {
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
// If it's a string, check for redaction or valid JSON
|
||||
if (typeof value === "string") {
|
||||
// If redacted, consider it valid (backend has the real value)
|
||||
if (isRedacted(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null && Object.keys(parsed).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid origin URL or wildcard pattern
|
||||
* @param origin - The origin URL to validate (supports wildcards like https://*.example.com)
|
||||
* @returns true if valid origin (protocol + hostname + optional port) or valid wildcard pattern
|
||||
*/
|
||||
export function isValidOrigin(origin: string): boolean {
|
||||
if (!origin || !origin.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow just "*" to mean allow everything
|
||||
if (origin.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle wildcard patterns
|
||||
if (origin.includes("*")) {
|
||||
return isValidWildcardOrigin(origin);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
|
||||
// Must have protocol and hostname
|
||||
if (!url.protocol || !url.hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be http or https
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not have path, query, or fragment (origin should be just protocol + hostname + port)
|
||||
if (url.pathname !== "/" || url.search || url.hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid wildcard origin pattern
|
||||
* @param origin - The wildcard origin pattern to validate
|
||||
* @returns true if valid wildcard pattern
|
||||
*/
|
||||
function isValidWildcardOrigin(origin: string): boolean {
|
||||
// Basic validation: must start with protocol
|
||||
if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the part after protocol
|
||||
const protocolEnd = origin.indexOf("://") + 3;
|
||||
const hostPart = origin.substring(protocolEnd);
|
||||
|
||||
// Must not have path, query, or fragment
|
||||
if (hostPart.includes("/") || hostPart.includes("?") || hostPart.includes("#")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle port if present
|
||||
let hostname = hostPart;
|
||||
if (hostPart.includes(":")) {
|
||||
const parts = hostPart.split(":");
|
||||
if (parts.length !== 2) return false;
|
||||
hostname = parts[0];
|
||||
const port = parts[1];
|
||||
// Validate port is a number
|
||||
if (!/^\d+$/.test(port) || parseInt(port) < 1 || parseInt(port) > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate wildcard patterns
|
||||
// Only allow wildcards at the beginning of subdomains
|
||||
if (hostname === "*") {
|
||||
return true; // Allow just * for any domain
|
||||
}
|
||||
|
||||
// Pattern like *.example.com
|
||||
if (hostname.startsWith("*.")) {
|
||||
const domain = hostname.substring(2);
|
||||
// Domain part after *. must be valid
|
||||
if (!domain || domain.includes("*") || domain.startsWith(".") || domain.endsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
// Basic domain validation - must have at least one dot and valid characters
|
||||
return /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain);
|
||||
}
|
||||
|
||||
// No other wildcard patterns are allowed
|
||||
if (hostname.includes("*")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an array of origin URLs
|
||||
* @param origins - Array of origin URLs to validate
|
||||
* @returns Object with validation result and invalid origins
|
||||
*/
|
||||
export function validateOrigins(origins: string[]): { isValid: boolean; invalidOrigins: string[] } {
|
||||
if (!origins || origins.length === 0) {
|
||||
return { isValid: true, invalidOrigins: [] };
|
||||
}
|
||||
|
||||
const invalidOrigins = origins.filter((origin) => !isValidOrigin(origin));
|
||||
|
||||
return {
|
||||
isValid: invalidOrigins.length === 0,
|
||||
invalidOrigins,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid Redis address
|
||||
* Supports formats:
|
||||
* - host:port (IPv4)
|
||||
* - [host]:port (IPv6)
|
||||
* - redis://host:port
|
||||
* - rediss://host:port
|
||||
* @param addr - The Redis address to validate
|
||||
* @returns true if valid Redis address
|
||||
*/
|
||||
export function isValidRedisAddress(addr: string): boolean {
|
||||
if (!addr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trim input once before processing
|
||||
const trimmedAddr = addr.trim();
|
||||
if (!trimmedAddr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle URL schemes (redis:// or rediss://)
|
||||
if (trimmedAddr.startsWith("redis://") || trimmedAddr.startsWith("rediss://")) {
|
||||
try {
|
||||
const url = new URL(trimmedAddr);
|
||||
const host = url.hostname;
|
||||
const port = url.port || "6379"; // Default Redis port
|
||||
|
||||
// Check if host is IPv6 (contains colons or is bracketed)
|
||||
const isIPv6Host = host.includes(":") || host.startsWith("[");
|
||||
const hostToValidate = isIPv6Host ? host.replace(/^\[|\]$/g, "") : host;
|
||||
|
||||
const isValidHostResult = isIPv6Host ? isValidIPv6(hostToValidate) : isValidHost(hostToValidate);
|
||||
return isValidHostResult && isValidPort(port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle IPv6 addresses in brackets [host]:port
|
||||
const ipv6Match = trimmedAddr.match(/^\[([^\]]+)\]:(\d+)$/);
|
||||
if (ipv6Match) {
|
||||
const [, host, port] = ipv6Match;
|
||||
return isValidIPv6(host) && isValidPort(port);
|
||||
}
|
||||
|
||||
// Handle standard host:port format
|
||||
const colonIndex = trimmedAddr.lastIndexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = trimmedAddr.substring(0, colonIndex);
|
||||
const port = trimmedAddr.substring(colonIndex + 1);
|
||||
|
||||
// Validate both host and port
|
||||
return isValidHost(host) && isValidPort(port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid host (hostname or IP address)
|
||||
* @param host - The host to validate
|
||||
* @returns true if valid host
|
||||
*/
|
||||
function isValidHost(host: string): boolean {
|
||||
if (!host || !host.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedHost = host.trim();
|
||||
|
||||
// Check if this looks like an IPv6 address (contains colons or is bracketed)
|
||||
if (trimmedHost.includes(":") || trimmedHost.startsWith("[")) {
|
||||
// Strip brackets if present and validate as IPv6
|
||||
const ipv6Host = trimmedHost.replace(/^\[|\]$/g, "");
|
||||
return isValidIPv6(ipv6Host);
|
||||
}
|
||||
|
||||
// Check for valid hostname/IPv4 patterns
|
||||
// Allow alphanumeric characters, dots, hyphens, and underscores
|
||||
const hostPattern = /^[a-zA-Z0-9._-]+$/;
|
||||
return hostPattern.test(trimmedHost) && trimmedHost.length <= 253;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid port number (strict digit-only validation)
|
||||
* @param port - The port to validate
|
||||
* @returns true if valid port
|
||||
*/
|
||||
function isValidPort(port: string): boolean {
|
||||
if (!port) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedPort = port.trim();
|
||||
|
||||
// Port must consist only of digits (no trailing characters like "6379abc")
|
||||
if (!/^\d+$/.test(trimmedPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert to number and check range
|
||||
const portNum = Number(trimmedPort);
|
||||
return portNum >= 1 && portNum <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid IPv6 address
|
||||
* @param host - The IPv6 address to validate (without brackets)
|
||||
* @returns true if valid IPv6 address
|
||||
*/
|
||||
function isValidIPv6(host: string): boolean {
|
||||
if (!host || !host.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedHost = host.trim();
|
||||
|
||||
// Basic IPv6 pattern validation
|
||||
// IPv6 addresses contain colons and hexadecimal characters
|
||||
const ipv6Pattern =
|
||||
/^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$|^::$|^::1$|^([0-9a-fA-F]{0,4}:){0,6}::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$/;
|
||||
|
||||
// Check basic pattern
|
||||
if (!ipv6Pattern.test(trimmedHost)) {
|
||||
// Also allow IPv6 with embedded IPv4 (e.g., ::ffff:192.168.1.1)
|
||||
const ipv6WithIpv4Pattern = /^([0-9a-fA-F]{0,4}:){1,6}(\d{1,3}\.){3}\d{1,3}$|^::([0-9a-fA-F]{0,4}:){0,5}(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!ipv6WithIpv4Pattern.test(trimmedHost)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation: check for valid hex groups and proper structure
|
||||
const parts = trimmedHost.split(":");
|
||||
|
||||
// IPv6 should not have more than 8 groups (unless it's compressed with ::)
|
||||
if (parts.length > 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid hexadecimal groups
|
||||
for (const part of parts) {
|
||||
if (part !== "" && !/^[0-9a-fA-F]{1,4}$/.test(part)) {
|
||||
// Allow IPv4 dotted notation in the last part
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const isJson = (text: string) => {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanJson = (text: unknown) => {
|
||||
try {
|
||||
if (typeof text === "string") return JSON.parse(text); // parse JSON strings
|
||||
if (Array.isArray(text)) return text; // keep arrays as-is
|
||||
if (text !== null && typeof text === "object") return text; // keep objects as-is
|
||||
if (typeof text === "number" || typeof text === "boolean") return text;
|
||||
return "Invalid payload";
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a request type is disabled for a provider
|
||||
* @param providerType - The provider type
|
||||
* @param requestType - The request type
|
||||
* @returns true if the request type is disabled
|
||||
*/
|
||||
export function isRequestTypeDisabled(providerType: BaseProvider | undefined, requestType: string): boolean {
|
||||
if (!providerType) return false;
|
||||
|
||||
const supportedRequests = PROVIDER_SUPPORTED_REQUESTS[providerType];
|
||||
if (!supportedRequests) return false; // If provider not in base list, allow all
|
||||
|
||||
return !supportedRequests.includes(requestType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans the path overrides by removing empty values
|
||||
* @param overrides - The path overrides to clean
|
||||
* @returns The cleaned path overrides
|
||||
*/
|
||||
export function cleanPathOverrides(overrides?: Record<string, string | undefined>) {
|
||||
if (!overrides) return undefined;
|
||||
|
||||
const entries = Object.entries(overrides)
|
||||
.map(([k, v]) => [k, v?.trim()])
|
||||
.filter(([, v]) => v && v !== "");
|
||||
|
||||
return entries.length ? (Object.fromEntries(entries) as Record<string, string>) : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user