Replace entire codebase with homarr-labs/homarr
This commit is contained in:
4
packages/validation/eslint.config.js
Normal file
4
packages/validation/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [...baseConfig];
|
||||
37
packages/validation/package.json
Normal file
37
packages/validation/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@homarr/validation",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"zod": "^4.2.1",
|
||||
"zod-form-data": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
36
packages/validation/src/app.ts
Normal file
36
packages/validation/src/app.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const appHrefSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.url()
|
||||
.regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed)
|
||||
.or(z.literal(""))
|
||||
.transform((value) => (value.length === 0 ? null : value))
|
||||
.nullable();
|
||||
|
||||
export const appManageSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(512)
|
||||
.transform((value) => (value.length === 0 ? null : value))
|
||||
.nullable(),
|
||||
iconUrl: z.string().trim().min(1),
|
||||
href: appHrefSchema,
|
||||
pingUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.url()
|
||||
.regex(/^https?:\/\//) // Only allow http and https for security reasons (javascript: is not allowed)
|
||||
.or(z.literal(""))
|
||||
.transform((value) => (value.length === 0 ? null : value))
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const appCreateManySchema = z
|
||||
.array(appManageSchema.omit({ iconUrl: true }).and(z.object({ iconUrl: z.string().min(1).nullable() })))
|
||||
.min(1);
|
||||
|
||||
export const appEditSchema = appManageSchema.and(z.object({ id: z.string() }));
|
||||
96
packages/validation/src/board.ts
Normal file
96
packages/validation/src/board.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
boardPermissions,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
import { createSavePermissionsSchema } from "./permissions";
|
||||
import { commonItemSchema, sectionSchema } from "./shared";
|
||||
|
||||
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
||||
|
||||
const hexColorNullableSchema = hexColorSchema
|
||||
.or(z.literal(""))
|
||||
.nullable()
|
||||
.transform((value) => (value?.trim().length === 0 ? null : value));
|
||||
|
||||
export const boardNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[A-Za-z0-9-_]*$/);
|
||||
export const boardColumnCountSchema = z.number().min(1).max(24);
|
||||
|
||||
export const boardByNameSchema = z.object({
|
||||
name: boardNameSchema,
|
||||
});
|
||||
|
||||
export const boardRenameSchema = z.object({
|
||||
id: z.string(),
|
||||
name: boardNameSchema,
|
||||
});
|
||||
|
||||
export const boardDuplicateSchema = z.object({
|
||||
id: z.string(),
|
||||
name: boardNameSchema,
|
||||
});
|
||||
|
||||
export const boardChangeVisibilitySchema = z.object({
|
||||
id: z.string(),
|
||||
visibility: z.enum(["public", "private"]),
|
||||
});
|
||||
|
||||
const trimmedNullableString = z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((value) => (value?.trim().length === 0 ? null : value));
|
||||
|
||||
export const boardSavePartialSettingsSchema = z
|
||||
.object({
|
||||
pageTitle: trimmedNullableString,
|
||||
metaTitle: trimmedNullableString,
|
||||
logoImageUrl: trimmedNullableString,
|
||||
faviconImageUrl: trimmedNullableString,
|
||||
backgroundImageUrl: trimmedNullableString,
|
||||
backgroundImageAttachment: z.enum(backgroundImageAttachments.values),
|
||||
backgroundImageRepeat: z.enum(backgroundImageRepeats.values),
|
||||
backgroundImageSize: z.enum(backgroundImageSizes.values),
|
||||
primaryColor: hexColorSchema,
|
||||
secondaryColor: hexColorSchema,
|
||||
opacity: z.number().min(0).max(100),
|
||||
customCss: z.string().max(16384),
|
||||
iconColor: hexColorNullableSchema,
|
||||
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
|
||||
disableStatus: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const boardSaveLayoutsSchema = z.object({
|
||||
id: z.string(),
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().nonempty().max(32),
|
||||
columnCount: boardColumnCountSchema,
|
||||
breakpoint: z.number().min(0).max(32767),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const boardSaveSchema = z.object({
|
||||
id: z.string(),
|
||||
sections: z.array(sectionSchema),
|
||||
items: z.array(commonItemSchema),
|
||||
});
|
||||
|
||||
export const boardCreateSchema = z.object({
|
||||
name: boardNameSchema,
|
||||
columnCount: boardColumnCountSchema,
|
||||
isPublic: z.boolean(),
|
||||
});
|
||||
|
||||
export const boardSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions));
|
||||
44
packages/validation/src/certificates.ts
Normal file
44
packages/validation/src/certificates.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
export const certificateValidFileNameSchema = z.string().regex(/^[\w\-. ]+$/);
|
||||
|
||||
export const checkCertificateFile: z.core.CheckFn<File> = (context) => {
|
||||
const result = certificateValidFileNameSchema.safeParse(context.value.name);
|
||||
if (!result.success) {
|
||||
context.issues.push({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileName",
|
||||
params: {},
|
||||
}),
|
||||
input: context.value.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.value.name.endsWith(".crt") && !context.value.name.endsWith(".pem")) {
|
||||
context.issues.push({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileType",
|
||||
params: { expected: ".crt" },
|
||||
}),
|
||||
input: context.value.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.value.size > 1024 * 1024) {
|
||||
context.issues.push({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "1 MB" },
|
||||
}),
|
||||
input: context.value.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
16
packages/validation/src/common.ts
Normal file
16
packages/validation/src/common.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const paginatedSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.number().int().positive().default(10),
|
||||
page: z.number().int().positive().default(1),
|
||||
});
|
||||
|
||||
export const byIdSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const searchSchema = z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().positive().default(10),
|
||||
});
|
||||
11
packages/validation/src/enums.ts
Normal file
11
packages/validation/src/enums.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type CouldBeReadonlyArray<T> = T[] | readonly T[];
|
||||
|
||||
export const zodEnumFromArray = <T extends string>(array: CouldBeReadonlyArray<T>) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
z.enum([array[0]!, ...array.slice(1)]);
|
||||
|
||||
export const zodUnionFromArray = <T extends z.ZodType>(array: CouldBeReadonlyArray<T>) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
z.union([array[0]!, array[1]!, ...array.slice(2)]);
|
||||
114
packages/validation/src/form/i18n.spec.ts
Normal file
114
packages/validation/src/form/i18n.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
|
||||
import { createCustomErrorParams, zodErrorMap } from "./i18n";
|
||||
|
||||
const expectError = (error: z.core.$ZodIssue, key: string) => {
|
||||
expect(error.message).toContain(key);
|
||||
};
|
||||
|
||||
describe("i18n", () => {
|
||||
const t = ((key: string) => {
|
||||
return `${key}`;
|
||||
}) as TranslationFunction;
|
||||
z.config({
|
||||
customError: zodErrorMap(t),
|
||||
});
|
||||
|
||||
test("should return required error for string when passing null", () => {
|
||||
const schema = z.string();
|
||||
const result = schema.safeParse(null);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "required");
|
||||
});
|
||||
test("should return required error for empty string", () => {
|
||||
const schema = z.string().nonempty();
|
||||
const result = schema.safeParse("");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "required");
|
||||
});
|
||||
test("should return invalid email error", () => {
|
||||
const schema = z.string().email();
|
||||
const result = schema.safeParse("invalid-email");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "invalidEmail");
|
||||
});
|
||||
test("should return startsWith error", () => {
|
||||
const schema = z.string().startsWith("test");
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "startsWith");
|
||||
});
|
||||
test("should return endsWith error", () => {
|
||||
const schema = z.string().endsWith("test");
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "endsWith");
|
||||
});
|
||||
test("should return includes error", () => {
|
||||
const schema = z.string().includes("test");
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "includes");
|
||||
});
|
||||
test("should return tooSmall error for string", () => {
|
||||
const schema = z.string().min(5);
|
||||
const result = schema.safeParse("test");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooSmall.string");
|
||||
});
|
||||
test("should return tooSmall error for number", () => {
|
||||
const schema = z.number().min(5);
|
||||
const result = schema.safeParse(3);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooSmall.number");
|
||||
});
|
||||
test("should return tooBig error for string", () => {
|
||||
const schema = z.string().max(5);
|
||||
const result = schema.safeParse("too long");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooBig.string");
|
||||
});
|
||||
test("should return tooBig error for number", () => {
|
||||
const schema = z.number().max(5);
|
||||
const result = schema.safeParse(10);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooBig.number");
|
||||
});
|
||||
test("should return custom error", () => {
|
||||
const schema = z.string().refine((val) => val === "valid", {
|
||||
params: createCustomErrorParams({
|
||||
key: "boardAlreadyExists",
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "boardAlreadyExists");
|
||||
});
|
||||
});
|
||||
152
packages/validation/src/form/i18n.ts
Normal file
152
packages/validation/src/form/i18n.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { ScopedTranslationFunction, TranslationFunction, TranslationObject } from "@homarr/translation";
|
||||
|
||||
export const zodErrorMap = (t: TranslationFunction): z.core.$ZodErrorMap<z.core.$ZodIssue> => {
|
||||
return (issue) => {
|
||||
const error = handleError(issue);
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return t(`common.zod.errors.${error.key}`, (error.params ?? {}) as never);
|
||||
};
|
||||
};
|
||||
|
||||
type ValidTranslationKeys = Parameters<ScopedTranslationFunction<"common.zod.errors">>[0];
|
||||
|
||||
type HandlerReturnValue =
|
||||
| string
|
||||
| {
|
||||
key: ValidTranslationKeys;
|
||||
params?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
const handleError = (issue: z.core.$ZodRawIssue): HandlerReturnValue => {
|
||||
if (issue.code === "too_big") return handleTooBigError(issue);
|
||||
if (issue.code === "too_small") return handleTooSmallError(issue);
|
||||
if (issue.code === "invalid_format") return handleInvalidFormatError(issue);
|
||||
if (issue.code === "invalid_type" && issue.expected === "string" && issue.input === null) {
|
||||
return {
|
||||
key: "required",
|
||||
};
|
||||
}
|
||||
if (issue.code === "custom" && issue.params?.i18n) {
|
||||
const i18n = issue.params.i18n as { key: CustomErrorKey; params?: Record<string, string | number> };
|
||||
return {
|
||||
key: `custom.${i18n.key}`,
|
||||
params: i18n.params,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleTooBigError = (
|
||||
issue: Pick<z.core.$ZodIssueTooBig, "origin" | "maximum"> & { message?: string },
|
||||
): HandlerReturnValue => {
|
||||
if (issue.origin !== "string" && issue.origin !== "number") {
|
||||
return (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const origin = issue.origin as "string" | "number";
|
||||
|
||||
return {
|
||||
key: `tooBig.${origin}`,
|
||||
params: {
|
||||
maximum: issue.maximum.toString(),
|
||||
count: issue.maximum.toString(),
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleTooSmallError = (
|
||||
issue: Pick<z.core.$ZodIssueTooSmall, "origin" | "minimum"> & { message?: string },
|
||||
): HandlerReturnValue => {
|
||||
if (issue.origin !== "string" && issue.origin !== "number") {
|
||||
return (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const origin = issue.origin as "string" | "number";
|
||||
if (origin === "string" && issue.minimum === 1) {
|
||||
return {
|
||||
key: "required",
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
key: `tooSmall.${origin}`,
|
||||
params: {
|
||||
minimum: issue.minimum.toString(),
|
||||
count: issue.minimum.toString(),
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleInvalidFormatError = (
|
||||
issue: Pick<z.core.$ZodIssueInvalidStringFormat, "format"> & { message?: string },
|
||||
): HandlerReturnValue => {
|
||||
if (issue.format === "includes" && "includes" in issue && typeof issue.includes === "string") {
|
||||
return {
|
||||
key: "string.includes",
|
||||
params: {
|
||||
includes: issue.includes,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (issue.format === "ends_with" && "suffix" in issue && typeof issue.suffix === "string") {
|
||||
return {
|
||||
key: "string.endsWith",
|
||||
params: {
|
||||
endsWith: issue.suffix,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (issue.format === "starts_with" && "prefix" in issue && typeof issue.prefix === "string") {
|
||||
return {
|
||||
key: "string.startsWith",
|
||||
params: {
|
||||
startsWith: issue.prefix,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (issue.format === "email") {
|
||||
return {
|
||||
key: "string.invalidEmail",
|
||||
} as const;
|
||||
}
|
||||
|
||||
return (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
type CustomErrorKey = keyof TranslationObject["common"]["zod"]["errors"]["custom"];
|
||||
|
||||
export interface CustomErrorParams<TKey extends CustomErrorKey> {
|
||||
i18n: {
|
||||
key: TKey;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export const createCustomErrorParams = <TKey extends CustomErrorKey>(
|
||||
i18n: keyof CustomErrorParams<TKey>["i18n"]["params"] extends never
|
||||
? CustomErrorParams<TKey>["i18n"]["key"]
|
||||
: CustomErrorParams<TKey>["i18n"],
|
||||
) => (typeof i18n === "string" ? { i18n: { key: i18n, params: {} } } : { i18n });
|
||||
40
packages/validation/src/group.ts
Normal file
40
packages/validation/src/group.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { everyoneGroup, groupPermissionKeys } from "@homarr/definitions";
|
||||
|
||||
import { byIdSchema } from "./common";
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
|
||||
export const groupCreateSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine((value) => value !== everyoneGroup, {
|
||||
message: "'everyone' is a reserved group name",
|
||||
}),
|
||||
});
|
||||
|
||||
export const groupUpdateSchema = groupCreateSchema.merge(byIdSchema);
|
||||
|
||||
export const groupSettingsSchema = z.object({
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const groupSavePartialSettingsSchema = z.object({
|
||||
id: z.string(),
|
||||
settings: groupSettingsSchema.partial(),
|
||||
});
|
||||
|
||||
export const groupSavePermissionsSchema = z.object({
|
||||
groupId: z.string(),
|
||||
permissions: z.array(zodEnumFromArray(groupPermissionKeys)),
|
||||
});
|
||||
|
||||
export const groupSavePositionsSchema = z.object({
|
||||
positions: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const groupUserSchema = z.object({ groupId: z.string(), userId: z.string() });
|
||||
6
packages/validation/src/icons.ts
Normal file
6
packages/validation/src/icons.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const iconsFindSchema = z.object({
|
||||
searchText: z.string().optional(),
|
||||
limitPerGroup: z.number().min(1).max(500).default(12),
|
||||
});
|
||||
44
packages/validation/src/integration.ts
Normal file
44
packages/validation/src/integration.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { integrationKinds, integrationPermissions, integrationSecretKinds } from "@homarr/definitions";
|
||||
|
||||
import { appManageSchema } from "./app";
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
import { createSavePermissionsSchema } from "./permissions";
|
||||
|
||||
export const integrationCreateSchema = z.object({
|
||||
name: z.string().nonempty().max(127),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.regex(/^https?:\/\//), // Only allow http and https for security reasons (javascript: is not allowed)
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
attemptSearchEngineCreation: z.boolean(),
|
||||
app: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
})
|
||||
.or(appManageSchema)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const integrationUpdateSchema = z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string().nonempty().max(127),
|
||||
url: z.string().url(),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
appId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const integrationSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(integrationPermissions));
|
||||
39
packages/validation/src/media.ts
Normal file
39
packages/validation/src/media.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import z from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
export const supportedMediaUploadFormats = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"];
|
||||
|
||||
export const mediaUploadSchema = zfd.formData({
|
||||
files: zfd.repeatable(
|
||||
z.array(
|
||||
zfd.file().check((context) => {
|
||||
if (!supportedMediaUploadFormats.includes(context.value.type)) {
|
||||
context.issues.push({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileType",
|
||||
params: { expected: `one of ${supportedMediaUploadFormats.join(", ")}` },
|
||||
}),
|
||||
input: context.value.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.value.size > 1024 * 1024 * 32) {
|
||||
// Don't forget to update the limit in nginx.conf (client_max_body_size)
|
||||
context.issues.push({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "32 MB" },
|
||||
}),
|
||||
input: context.value.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
15
packages/validation/src/permissions.ts
Normal file
15
packages/validation/src/permissions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const createSavePermissionsSchema = <const TPermissionSchema extends z.core.$ZodEnum>(
|
||||
permissionSchema: TPermissionSchema,
|
||||
) => {
|
||||
return z.object({
|
||||
entityId: z.string(),
|
||||
permissions: z.array(
|
||||
z.object({
|
||||
principalId: z.string(),
|
||||
permission: permissionSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
38
packages/validation/src/search-engine.ts
Normal file
38
packages/validation/src/search-engine.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ZodTypeAny } from "zod/v4";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { SearchEngineType } from "@homarr/definitions";
|
||||
|
||||
const genericSearchEngine = z.object({
|
||||
type: z.literal("generic" satisfies SearchEngineType),
|
||||
urlTemplate: z.string().min(1).startsWith("http").includes("%s"), // Only allow http and https for security reasons (javascript: is not allowed)
|
||||
});
|
||||
|
||||
const fromIntegrationSearchEngine = z.object({
|
||||
type: z.literal("fromIntegration" satisfies SearchEngineType),
|
||||
integrationId: z.string().optional(),
|
||||
});
|
||||
|
||||
const baseSearchEngineManageSchema = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
short: z.string().min(1).max(8),
|
||||
iconUrl: z.string().min(1),
|
||||
description: z.string().max(512).nullable(),
|
||||
});
|
||||
|
||||
const createManageSearchEngineSchema = <T extends ZodTypeAny>(
|
||||
callback: (schema: typeof baseSearchEngineManageSchema) => T,
|
||||
) =>
|
||||
z
|
||||
.discriminatedUnion("type", [genericSearchEngine, fromIntegrationSearchEngine])
|
||||
.and(callback(baseSearchEngineManageSchema));
|
||||
|
||||
export const searchEngineManageSchema = createManageSearchEngineSchema((schema) => schema);
|
||||
|
||||
export const searchEngineEditSchema = createManageSearchEngineSchema((schema) =>
|
||||
schema
|
||||
.extend({
|
||||
id: z.string(),
|
||||
})
|
||||
.omit({ short: true }),
|
||||
);
|
||||
16
packages/validation/src/settings.ts
Normal file
16
packages/validation/src/settings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const settingsInitSchema = z.object({
|
||||
analytics: z.object({
|
||||
enableGeneral: z.boolean(),
|
||||
enableWidgetData: z.boolean(),
|
||||
enableIntegrationData: z.boolean(),
|
||||
enableUserData: z.boolean(),
|
||||
}),
|
||||
crawlingAndIndexing: z.object({
|
||||
noIndex: z.boolean(),
|
||||
noFollow: z.boolean(),
|
||||
noTranslate: z.boolean(),
|
||||
noSiteLinksSearchBox: z.boolean(),
|
||||
}),
|
||||
});
|
||||
85
packages/validation/src/shared.ts
Normal file
85
packages/validation/src/shared.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { integrationKinds, widgetKinds } from "@homarr/definitions";
|
||||
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
|
||||
export const integrationSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export type BoardItemIntegration = z.infer<typeof integrationSchema>;
|
||||
|
||||
export const itemAdvancedOptionsSchema = z.object({
|
||||
title: z.string().max(64).nullable().default(null),
|
||||
customCssClasses: z.array(z.string()).default([]),
|
||||
borderColor: z.string().default(""),
|
||||
});
|
||||
|
||||
export type BoardItemAdvancedOptions = z.infer<typeof itemAdvancedOptionsSchema>;
|
||||
|
||||
export const sharedItemSchema = z.object({
|
||||
id: z.string(),
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
layoutId: z.string(),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
sectionId: z.string(),
|
||||
}),
|
||||
),
|
||||
integrationIds: z.array(z.string()),
|
||||
advancedOptions: itemAdvancedOptionsSchema,
|
||||
});
|
||||
|
||||
export const commonItemSchema = z
|
||||
.object({
|
||||
kind: zodEnumFromArray(widgetKinds),
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.and(sharedItemSchema);
|
||||
|
||||
const categorySectionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.literal("category"),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
collapsed: z.boolean(),
|
||||
});
|
||||
|
||||
const emptySectionSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("empty"),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
});
|
||||
|
||||
export const dynamicSectionOptionsSchema = z.object({
|
||||
title: z.string().max(20).default(""),
|
||||
customCssClasses: z.array(z.string()).default([]),
|
||||
borderColor: z.string().default(""),
|
||||
});
|
||||
|
||||
const dynamicSectionSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("dynamic"),
|
||||
options: dynamicSectionOptionsSchema,
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
layoutId: z.string(),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
parentSectionId: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const sectionSchema = z.union([categorySectionSchema, emptySectionSchema, dynamicSectionSchema]);
|
||||
149
packages/validation/src/user.ts
Normal file
149
packages/validation/src/user.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
// We always want the lowercase version of the username to compare it in a case-insensitive way
|
||||
export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255);
|
||||
|
||||
const regexCheck = (regex: RegExp) => (value: string) => regex.test(value);
|
||||
export const passwordRequirements = [
|
||||
{ check: (value) => value.length >= 8, value: "length" },
|
||||
{ check: regexCheck(/[a-z]/), value: "lowercase" },
|
||||
{ check: regexCheck(/[A-Z]/), value: "uppercase" },
|
||||
{ check: regexCheck(/\d/), value: "number" },
|
||||
{ check: regexCheck(/[$&+,:;=?@#|'<>.^*()%!\-~`"_/\\[\]{}]/), value: "special" },
|
||||
] satisfies {
|
||||
check: (value: string) => boolean;
|
||||
value: keyof TranslationObject["user"]["field"]["password"]["requirement"];
|
||||
}[];
|
||||
|
||||
export const userPasswordSchema = z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(255)
|
||||
.refine(
|
||||
(value) => {
|
||||
return passwordRequirements.every((requirement) => requirement.check(value));
|
||||
},
|
||||
{
|
||||
params: createCustomErrorParams({
|
||||
key: "passwordRequirements",
|
||||
params: {},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const addConfirmPasswordRefinement = <
|
||||
TSchema extends z.ZodObject<{ password: z.core.$ZodString; confirmPassword: z.core.$ZodString }, z.core.$strip>,
|
||||
>(
|
||||
schema: TSchema,
|
||||
) => {
|
||||
return schema.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
params: createCustomErrorParams({
|
||||
key: "passwordsDoNotMatch",
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export const userBaseCreateSchema = z.object({
|
||||
username: usernameSchema,
|
||||
password: userPasswordSchema,
|
||||
confirmPassword: z.string(),
|
||||
email: z.string().email().or(z.string().length(0)).optional(),
|
||||
groupIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const userCreateSchema = addConfirmPasswordRefinement(userBaseCreateSchema);
|
||||
|
||||
export const userInitSchema = addConfirmPasswordRefinement(userBaseCreateSchema.omit({ groupIds: true }));
|
||||
|
||||
export const userSignInSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ldapSignInSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
// Prevent special characters that could lead to LDAP injection attacks
|
||||
.regex(/^[^\\,+<>;"=)(*|!&]+$/, {
|
||||
message: "Invalid characters in ldap username",
|
||||
}),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const userRegistrationSchema = addConfirmPasswordRefinement(
|
||||
z.object({
|
||||
username: usernameSchema,
|
||||
password: userPasswordSchema,
|
||||
confirmPassword: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const userRegistrationApiSchema = userRegistrationSchema.and(
|
||||
z.object({
|
||||
inviteId: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const userEditProfileSchema = z.object({
|
||||
id: z.string(),
|
||||
name: usernameSchema,
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.or(z.literal(""))
|
||||
.transform((value) => (value === "" ? null : value))
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const baseChangePasswordSchema = z.object({
|
||||
previousPassword: z.string().min(1),
|
||||
password: userPasswordSchema,
|
||||
confirmPassword: z.string(),
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export const userChangePasswordSchema = addConfirmPasswordRefinement(baseChangePasswordSchema.omit({ userId: true }));
|
||||
|
||||
export const userChangePasswordApiSchema = addConfirmPasswordRefinement(baseChangePasswordSchema);
|
||||
|
||||
export const userChangeHomeBoardsSchema = z.object({
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const userChangeSearchPreferencesSchema = z.object({
|
||||
defaultSearchEngineId: z.string().min(1).nullable(),
|
||||
openInNewTab: z.boolean(),
|
||||
});
|
||||
|
||||
export const userChangeColorSchemeSchema = z.object({
|
||||
colorScheme: zodEnumFromArray(colorSchemes),
|
||||
});
|
||||
|
||||
export const userFirstDayOfWeekSchema = z.object({
|
||||
firstDayOfWeek: z
|
||||
.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success)
|
||||
.meta({
|
||||
override: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 6,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const userPingIconsEnabledSchema = z.object({
|
||||
pingIconsEnabled: z.boolean(),
|
||||
});
|
||||
12
packages/validation/src/widgets/media-request.ts
Normal file
12
packages/validation/src/widgets/media-request.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const mediaRequestOptionsSchema = z.object({
|
||||
mediaId: z.number(),
|
||||
mediaType: z.enum(["tv", "movie"]),
|
||||
});
|
||||
|
||||
export const mediaRequestRequestSchema = z.object({
|
||||
mediaType: z.enum(["tv", "movie"]),
|
||||
mediaId: z.number(),
|
||||
seasons: z.array(z.number().min(0)).optional(),
|
||||
});
|
||||
8
packages/validation/tsconfig.json
Normal file
8
packages/validation/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user