Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add provider input validation & type safety #2430

Open
wants to merge 52 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
eb3eb4c
refactor provider form component
theedigerati Oct 11, 2024
606f193
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Oct 13, 2024
a3f2e2b
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Oct 14, 2024
4bad410
add backend provider input validations
theedigerati Oct 15, 2024
929dea1
fix circular import errors
theedigerati Oct 16, 2024
16f9255
add client-side validation with zod
theedigerati Oct 18, 2024
c6844fc
add backend validation for providers
theedigerati Oct 20, 2024
d6eaa40
add backend validation for 7 providers
theedigerati Oct 21, 2024
d28ba1a
add backend provider validation & switch input
theedigerati Oct 22, 2024
83d7ed0
add validation for `any_url` - url with any scheme
theedigerati Oct 24, 2024
f415058
add backend validation for 7 providers
theedigerati Oct 24, 2024
7518ad2
add backend validation for 7 providers
theedigerati Oct 26, 2024
fa72706
fix form value empty string in state bug
theedigerati Oct 27, 2024
990ff83
remove `http_url` validation type
theedigerati Oct 31, 2024
6e78687
add validation tests for `any_http_url1 and `port`
theedigerati Oct 31, 2024
a18e2fa
add validation tests for https_url, any_url & tld
theedigerati Nov 1, 2024
c7b939f
add provider form validation file
theedigerati Nov 2, 2024
4e77c5c
add new validation logic for url
theedigerati Nov 4, 2024
0369bd6
add validation for urls without scheme
theedigerati Nov 5, 2024
ab9908f
complete validation for 8 providers
theedigerati Nov 6, 2024
6826e1c
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 7, 2024
ac0a246
add more provider tests and clean up form
theedigerati Nov 10, 2024
9cdf0ef
fix file type validation bug on provider config
theedigerati Nov 10, 2024
605c188
add backend provider config validation
theedigerati Nov 10, 2024
5ef71eb
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 10, 2024
f3fdc6d
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 11, 2024
c7f4aa8
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 14, 2024
a4289af
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 14, 2024
9cb18e3
update provider config
theedigerati Nov 14, 2024
4ea353a
Merge branch 'main' into provider-input-validation
Matvey-Kuk Nov 18, 2024
9061975
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 18, 2024
b9d0f4b
add zod
theedigerati Nov 18, 2024
5a4fe2b
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 22, 2024
aa13f25
HttpsUrl should handle validation & transformation
theedigerati Nov 23, 2024
b5126ac
cleanup provider validation logic
theedigerati Nov 23, 2024
32da067
fix provider form render bug
theedigerati Nov 23, 2024
860873b
add tests for custom validation fields
theedigerati Nov 24, 2024
e2bd55b
add multihost url validation
theedigerati Nov 24, 2024
adc20b3
add multihost validation tp kafka & mongo
theedigerati Nov 24, 2024
bfb706e
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 24, 2024
03afb4e
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 25, 2024
5d5462b
update mysql config validation
theedigerati Nov 26, 2024
5f9be5a
change validation logic from on submit to on input
theedigerati Nov 27, 2024
71472ca
cleanup validation logic & tests
theedigerati Nov 27, 2024
89fb106
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 28, 2024
9011d2a
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 28, 2024
f4fe709
update client-side validation logic
theedigerati Nov 28, 2024
e837633
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 28, 2024
12ed2c7
fix validation & tests bugs
theedigerati Nov 28, 2024
4b33e68
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 28, 2024
303a960
code review updates
theedigerati Nov 29, 2024
3e7b095
Merge remote-tracking branch 'upstream/main' into provider-input-vali…
theedigerati Nov 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 343 additions & 0 deletions keep-ui/app/(keep)/providers/form-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
import { z } from "zod";
import { Provider } from "./providers";

type URLOptions = {
protocols: string[];
requireTld: boolean;
requireProtocol: boolean;
requirePort: boolean;
alllowMultihost: boolean;
validateLength: boolean;
maxLength: number;
};

type ValidatorRes = { success: true } | { success: false; msg: string };

const defaultURLOptions: URLOptions = {
protocols: [],
requireTld: false,
requireProtocol: true,
requirePort: false,
alllowMultihost: false,
validateLength: true,
maxLength: 2 ** 16,
};

function mergeOptions<T extends Record<string, unknown>>(
defaults: T,
opts?: Partial<T>
): T {
if (!opts) return defaults;
return { ...defaults, ...opts };
}

const error = (msg: string) => ({ success: false, msg });
const urlError = error("Please provide a valid URL");
const protocolError = error("A valid URL protocol is required");
const relProtocolError = error("A protocol-relavie URL is not allowed");
const multiProtocolError = error("URL cannot have more than one protocol");
const missingPortError = error("A URL with a port number is required");
const portError = error("Invalid port number");
const hostError = error("Invalid URL host");
const hostWildcardError = error("Wildcard in URL host is not allowed");
const multihostError = error("Multiple hosts are not allowed");
const multihostProtocolError = error("Invalid multihost protocol");
const tldError = error(
"URL must contain a valid TLD e.g .com, .io, .dev, .net"
);

function getProtocolError(protocols: URLOptions["protocols"]) {
if (protocols.length === 0) return protocolError;
if (protocols.length === 1)
return error(`A URL with \`${protocols[0]}\` protocol is required`);
if (protocols.length === 2)
return error(
`A URL with \`${protocols[0]}\` or \`${protocols[1]}\` protocol is required`
);
const lst = protocols.length - 1;
const wrap = (acc: string, p: string) => acc + `\`${p}\``;
const optsStr = protocols.reduce(
(acc, p, i) =>
i === lst
? wrap(acc, p)
: i === lst - 1
? wrap(acc, p) + " or "
: wrap(acc, p) + ", ",
""
);
return error(`A URL with one of ${optsStr} protocols is required`);
}

function isFQDN(str: string, options?: Partial<URLOptions>): ValidatorRes {
const opts = mergeOptions(defaultURLOptions, options);

if (str[str.length - 1] === ".") return hostError; // trailing dot not allowed
if (str.indexOf("*.") === 0) return hostWildcardError; // wildcard not allowed

const parts = str.split(".");
const tld = parts[parts.length - 1];
const tldRegex =
/^([a-z\u00A1-\u00A8\u00AA-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}|xn[a-z0-9-]{2,})$/i;

if (
opts.requireTld &&
(parts.length < 2 || !tldRegex.test(tld) || /\s/.test(tld))
)
return tldError;

const partsValid = parts.every((part) => {
if (!/^[a-z_\u00a1-\uffff0-9-]+$/i.test(part)) {
return false;
}

// disallow full-width chars
if (/[\uff01-\uff5e]/.test(part)) {
return false;
}

// disallow parts starting or ending with hyphen
if (/^-|-$/.test(part)) {
return false;
}

return true;
});

return partsValid ? { success: true } : hostError;
}

function isIP(str: string) {
const validation = z.string().ip().safeParse(str);
return validation.success;
}

function validateHost(hostname: string, opts: URLOptions): ValidatorRes {
let host: string;
let port: number;
let portStr: string = "";
let split: string[];

// extract ipv6 & port
const wrapped_ipv6 = /^\[([^\]]+)\](?::([0-9]+))?$/;
const ipv6Match = hostname.match(wrapped_ipv6);
if (ipv6Match) {
host = ipv6Match[1];
portStr = ipv6Match[2];
} else {
split = hostname.split(":");
host = split.shift() ?? "";
if (split.length) portStr = split.join(":");
}

if (portStr.length) {
port = parseInt(portStr, 10);
if (Number.isNaN(port)) return urlError;
if (port <= 0 || port > 65_535) return portError;
} else if (opts.requirePort) return missingPortError;

if (!host) return hostError;
if (isIP(host)) return { success: true };
return isFQDN(host, opts);
}

function isURL(str: string, options?: Partial<URLOptions>): ValidatorRes {
const opts = mergeOptions(defaultURLOptions, options);

if (str.length === 0 || /[\s<>]/.test(str)) return urlError;
if (opts.validateLength && str.length > opts.maxLength) {
return error(`Invalid url length, max of ${opts.maxLength} expected.`);
}

let url = str;
let split: string[];

split = url.split("#");
url = split.shift() ?? "";

split = url.split("?");
url = split.shift() ?? "";

if (url.slice(0, 2) === "//") return relProtocolError;

// extract protocol & validate
split = url.split("://");
if (split.length > 2) return multiProtocolError;
if (split.length > 1) {
const protocol = split.shift()?.toLowerCase() ?? "";
if (opts.protocols.length && opts.protocols.indexOf(protocol) === -1)
return getProtocolError(opts.protocols);
if (protocol.includes(",")) return multihostProtocolError;
url = split.join("://");
} else if (opts.requireProtocol) {
return getProtocolError(opts.protocols);
}

split = url.split("/");
url = split.shift() ?? "";
if (!url.length) return urlError;

// extract auth details & validate
split = url.split("@");
if (split.length > 1 && !split[0]) return urlError;
if (split.length > 1) {
const auth = split.shift() ?? "";
if (auth.split(":").length > 2) return urlError;
const [user, pass] = auth.split(":");
if (!user && !pass) return urlError;
}
const hostname = split.join("@");

// validate multihost
split = hostname.split(",");
if (split.length > 1 && !opts.alllowMultihost) return multihostError;
if (split.length > 1) {
for (const host of split) {
const res = validateHost(host, opts);
if (!res.success) return res;
}
return { success: true };
}
return validateHost(hostname, opts);
}

const required_error = "This field is required";

function getBaseUrlSchema(options?: Partial<URLOptions>) {
const urlStr = z.string({ required_error });
const schema = urlStr.superRefine((url, ctx) => {
const valdn = isURL(url, options);
if (valdn.success) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: valdn.msg,
});
});
return schema;
}

export function getZodSchema(fields: Provider["config"], installed: boolean) {
const portError = "Invalid port number";

const kvPairs = Object.entries(fields).map(([field, config]) => {
if (config.type === "form") {
const baseSchema = z.record(z.string(), z.string()).array();
const schema = config.required
? baseSchema.nonempty({
message: "At least one key-value entry should be provided.",
})
: baseSchema.optional();
return [field, schema];
}

if (config.type === "file") {
const baseSchema = z
.instanceof(File, { message: "Please upload a file here." })
.or(z.string())
.refine(
(file) => {
if (config.file_type == undefined) return true;
if (config.file_type.length <= 1) return true;
if (typeof file === "string" && installed) return true;
return (
typeof file !== "string" && config.file_type.includes(file.type)
);
},
{
message:
config.file_type && config.file_type?.split(",").length > 1
? `File type should be one of ${config.file_type}.`
: `File should be of type ${config.file_type}.`,
}
);
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.type === "switch") {
const schema = config.required ? z.boolean() : z.boolean().optional();
return [field, schema];
}

if (config.validation === "any_url") {
const baseSchema = getBaseUrlSchema();
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.validation === "any_http_url") {
const baseSchema = getBaseUrlSchema({ protocols: ["http", "https"] });
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.validation === "https_url") {
const baseSchema = getBaseUrlSchema({
protocols: ["https"],
requireTld: true,
maxLength: 2083,
});
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.validation === "no_scheme_url") {
const baseSchema = getBaseUrlSchema({ requireProtocol: false });
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.validation === "multihost_url") {
const baseSchema = getBaseUrlSchema({ alllowMultihost: true });
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.validation === "no_scheme_multihost_url") {
const baseSchema = getBaseUrlSchema({
alllowMultihost: true,
requireProtocol: false,
});
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.validation === "tld") {
const baseSchema = z
.string({ required_error })
.regex(new RegExp(/\.[a-z]{2,63}$/), {
message: "Please provide a valid TLD e.g .com, .io, .dev, .net",
});
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}

if (config.validation === "port") {
const baseSchema = z
.string({ required_error })
.pipe(
z.coerce
.number({ invalid_type_error: portError })
.min(1, { message: portError })
.max(65_535, { message: portError })
);
const schema = config.required ? baseSchema : baseSchema.optional();
return [field, schema];
}
return [
field,
config.required
? z
.string({ required_error })
.trim()
.min(1, { message: required_error })
: z.string().optional(),
];
});
return z.object({
provider_name: z
.string({ required_error })
.trim()
.min(1, { message: required_error }),
...Object.fromEntries(kvPairs),
});
}
Loading