Files
bifrost/ui/lib/utils/validation.ts
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

592 lines
16 KiB
TypeScript

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;
}