Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

View File

@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

View 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"
}
}

View 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() }));

View 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));

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

View 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),
});

View 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)]);

View 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");
});
});

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

View 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() });

View 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),
});

View 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));

View 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;
}
}),
),
),
});

View 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,
}),
),
});
};

View 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 }),
);

View 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(),
}),
});

View 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]);

View 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(),
});

View 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(),
});

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}