refactor: move from arktype to zod (#637)

chore: gen api-client
This commit is contained in:
Nico 2026-03-09 18:27:03 +01:00 committed by Nicolas Meienberger
parent b35a20f43d
commit 55dbcf0eb3
58 changed files with 3776 additions and 3797 deletions

View file

@ -50,6 +50,7 @@
"no-unused-vars": [ "no-unused-vars": [
"warn", "warn",
{ {
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_",
"argsIgnorePattern": "^_" "argsIgnorePattern": "^_"
} }

View file

@ -457,7 +457,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
/** /**
* List files in a volume directory * List files in a volume directory
*/ */
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, string | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>( export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, number | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore // @ts-ignore
{ {
queryFn: async ({ pageParam, queryKey, signal }) => { queryFn: async ({ pageParam, queryKey, signal }) => {
@ -747,7 +747,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
/** /**
* List files and directories in a snapshot * List files and directories in a snapshot
*/ */
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, string | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>( export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, number | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore // @ts-ignore
{ {
queryFn: async ({ pageParam, queryKey, signal }) => { queryFn: async ({ pageParam, queryKey, signal }) => {

View file

@ -38,7 +38,7 @@ export const createClient = (config: Config = {}): Client => {
...options, ...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers), headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined, serializedBody: undefined as string | undefined,
}; };
if (opts.security) { if (opts.security) {
@ -53,7 +53,7 @@ export const createClient = (config: Config = {}): Client => {
} }
if (opts.body !== undefined && opts.bodySerializer) { if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body); opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
} }
// remove Content-Type header if body is empty to avoid sending invalid requests // remove Content-Type header if body is empty to avoid sending invalid requests
@ -259,8 +259,10 @@ export const createClient = (config: Config = {}): Client => {
}); });
}; };
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return { return {
buildUrl, buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'), connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'), delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'), get: makeMethodFn('GET'),

View file

@ -191,7 +191,7 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn>
*/ */
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = ( export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>, override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T> | Promise<Config<Required<ClientOptions> & T>>; ) => Config<Required<ClientOptions> & T>;
export interface TDataShape { export interface TDataShape {
body?: unknown; body?: unknown;

View file

@ -5,7 +5,7 @@ import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerialize
export type QuerySerializer = (query: Record<string, unknown>) => string; export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any; export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = { type QuerySerializerOptionsObject = {
allowReserved?: boolean; allowReserved?: boolean;
@ -40,12 +40,10 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
}; };
export const formDataBodySerializer = { export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( bodySerializer: (body: unknown): FormData => {
body: T,
): FormData => {
const data = new FormData(); const data = new FormData();
Object.entries(body).forEach(([key, value]) => { Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }
@ -61,15 +59,15 @@ export const formDataBodySerializer = {
}; };
export const jsonBodySerializer = { export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string => bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
}; };
export const urlSearchParamsBodySerializer = { export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => { bodySerializer: (body: unknown): string => {
const data = new URLSearchParams(); const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => { Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }

File diff suppressed because it is too large Load diff

View file

@ -74,11 +74,7 @@ export const SnapshotTreeBrowser = ({
return await queryClient.ensureQueryData( return await queryClient.ensureQueryData(
listSnapshotFilesOptions({ listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId }, path: { shortId: repositoryId, snapshotId },
query: { query: { path, offset: offset, limit: pageSize },
path,
offset: offset.toString(),
limit: pageSize.toString(),
},
}), }),
); );
}, },
@ -86,11 +82,7 @@ export const SnapshotTreeBrowser = ({
void queryClient.prefetchQuery( void queryClient.prefetchQuery(
listSnapshotFilesOptions({ listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId }, path: { shortId: repositoryId, snapshotId },
query: { query: { path, offset: 0, limit: pageSize },
path,
offset: "0",
limit: pageSize.toString(),
},
}), }),
); );
}, },

View file

@ -24,7 +24,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
return await queryClient.ensureQueryData( return await queryClient.ensureQueryData(
listFilesOptions({ listFilesOptions({
path: { shortId: volumeId }, path: { shortId: volumeId },
query: { path, offset: offset?.toString() }, query: { path, offset: offset },
}), }),
); );
}, },

View file

@ -1,5 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@ -16,13 +15,17 @@ import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username"; import { normalizeUsername } from "~/lib/username";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section"; import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
import { z } from "zod";
const loginSchema = type({ const loginSchema = z.object({
username: "2<=string<=50", username: z
password: "string>=1", .string()
.min(2, "Username must be at least 2 characters")
.max(50, "Username must be at most 50 characters"),
password: z.string().min(1, "Password is required"),
}); });
type LoginFormValues = typeof loginSchema.inferIn; type LoginFormValues = z.input<typeof loginSchema>;
type LoginPageProps = { type LoginPageProps = {
error?: string; error?: string;
@ -40,7 +43,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null; const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const form = useForm<LoginFormValues>({ const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: { defaultValues: {
username: "", username: "",
password: "", password: "",

View file

@ -1,5 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -19,24 +18,28 @@ import { authClient } from "~/client/lib/auth-client";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username"; import { normalizeUsername } from "~/lib/username";
import { z } from "zod";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];
const onboardingSchema = type({ const onboardingSchema = z.object({
username: type("2<=string<=30").pipe(normalizeUsername), username: z.string().min(2).max(30).transform(normalizeUsername),
email: type("string.email").pipe((str) => str.trim().toLowerCase()), email: z
password: "string>=8", .string()
confirmPassword: "string>=1", .email()
.transform((str) => str.trim().toLowerCase()),
password: z.string().min(8),
confirmPassword: z.string().min(1),
}); });
type OnboardingFormValues = typeof onboardingSchema.inferIn; type OnboardingFormValues = z.input<typeof onboardingSchema>;
export function OnboardingPage() { export function OnboardingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const form = useForm<OnboardingFormValues>({ const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema), resolver: zodResolver(onboardingSchema),
defaultValues: { defaultValues: {
username: "", username: "",
password: "", password: "",

View file

@ -1,4 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
@ -13,7 +13,7 @@ import { FrequencySection } from "./frequency-section";
import { PathsSection } from "./paths-section"; import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section"; import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section"; import { SummarySection } from "./summary-section";
import { cleanSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types"; import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues } from "./utils"; import { backupScheduleToFormValues } from "./utils";
export type { BackupScheduleFormValues }; export type { BackupScheduleFormValues };
@ -29,7 +29,7 @@ type Props = {
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => { export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<InternalFormValues>({ const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof import("./types").internalFormSchema), resolver: zodResolver(internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues), defaultValues: backupScheduleToFormValues(initialValues),
}); });

View file

@ -22,7 +22,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
type="number" type="number"
min={0} min={0}
placeholder="Optional" placeholder="Optional"
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the N most recent snapshots.</FormDescription> <FormDescription>Keep the N most recent snapshots.</FormDescription>
@ -43,7 +43,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="Optional" placeholder="Optional"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N hourly snapshots.</FormDescription> <FormDescription>Keep the last N hourly snapshots.</FormDescription>
@ -64,7 +64,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="e.g., 7" placeholder="e.g., 7"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N daily snapshots.</FormDescription> <FormDescription>Keep the last N daily snapshots.</FormDescription>
@ -85,7 +85,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="e.g., 4" placeholder="e.g., 4"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N weekly snapshots.</FormDescription> <FormDescription>Keep the last N weekly snapshots.</FormDescription>
@ -106,7 +106,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="e.g., 6" placeholder="e.g., 6"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N monthly snapshots.</FormDescription> <FormDescription>Keep the last N monthly snapshots.</FormDescription>
@ -127,7 +127,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="Optional" placeholder="Optional"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N yearly snapshots.</FormDescription> <FormDescription>Keep the last N yearly snapshots.</FormDescription>

View file

@ -1,30 +1,27 @@
import { type } from "arktype"; import { z } from "zod";
import { deepClean } from "~/utils/object";
export const internalFormSchema = type({ export const internalFormSchema = z.object({
name: "1 <= string <= 128", name: z.string().min(1).max(128),
repositoryId: "string", repositoryId: z.string(),
excludePatternsText: "string?", excludePatternsText: z.string().optional(),
excludeIfPresentText: "string?", excludeIfPresentText: z.string().optional(),
includePatternsText: "string?", includePatternsText: z.string().optional(),
includePatterns: "string[]?", includePatterns: z.array(z.string()).optional(),
frequency: "string", frequency: z.string(),
dailyTime: "string?", dailyTime: z.string().optional(),
weeklyDay: "string?", weeklyDay: z.string().optional(),
monthlyDays: "string[]?", monthlyDays: z.array(z.string()).optional(),
cronExpression: "string?", cronExpression: z.string().optional(),
keepLast: "number?", keepLast: z.number().optional(),
keepHourly: "number?", keepHourly: z.number().optional(),
keepDaily: "number?", keepDaily: z.number().optional(),
keepWeekly: "number?", keepWeekly: z.number().optional(),
keepMonthly: "number?", keepMonthly: z.number().optional(),
keepYearly: "number?", keepYearly: z.number().optional(),
oneFileSystem: "boolean?", oneFileSystem: z.boolean().optional(),
customResticParamsText: "string?", customResticParamsText: z.string().optional(),
}); });
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
export const weeklyDays = [ export const weeklyDays = [
{ label: "Monday", value: "1" }, { label: "Monday", value: "1" },
{ label: "Tuesday", value: "2" }, { label: "Tuesday", value: "2" },
@ -35,7 +32,7 @@ export const weeklyDays = [
{ label: "Sunday", value: "0" }, { label: "Sunday", value: "0" },
]; ];
export type InternalFormValues = typeof internalFormSchema.infer; export type InternalFormValues = z.infer<typeof internalFormSchema>;
export type BackupScheduleFormValues = Omit< export type BackupScheduleFormValues = Omit<
InternalFormValues, InternalFormValues,

View file

@ -1,8 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { import {
Form, Form,
FormControl, FormControl,
@ -14,7 +13,17 @@ import {
} from "~/client/components/ui/form"; } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { notificationConfigSchemaBase } from "~/schemas/notifications"; import {
customNotificationConfigSchema,
discordNotificationConfigSchema,
emailNotificationConfigSchema,
genericNotificationConfigSchema,
gotifyNotificationConfigSchema,
ntfyNotificationConfigSchema,
pushoverNotificationConfigSchema,
slackNotificationConfigSchema,
telegramNotificationConfigSchema,
} from "~/schemas/notifications";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { import {
CustomForm, CustomForm,
@ -28,12 +37,21 @@ import {
TelegramForm, TelegramForm,
} from "./notification-forms"; } from "./notification-forms";
export const formSchema = type({ const baseFields = { name: z.string().min(2).max(32) };
name: "2<=string<=32",
}).and(notificationConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type NotificationFormValues = typeof formSchema.inferIn; export const formSchema = z.discriminatedUnion("type", [
emailNotificationConfigSchema.extend(baseFields),
slackNotificationConfigSchema.extend(baseFields),
discordNotificationConfigSchema.extend(baseFields),
gotifyNotificationConfigSchema.extend(baseFields),
ntfyNotificationConfigSchema.extend(baseFields),
pushoverNotificationConfigSchema.extend(baseFields),
telegramNotificationConfigSchema.extend(baseFields),
genericNotificationConfigSchema.extend(baseFields),
customNotificationConfigSchema.extend(baseFields),
]);
export type NotificationFormValues = z.input<typeof formSchema>;
type Props = { type Props = {
onSubmit: (values: NotificationFormValues) => void; onSubmit: (values: NotificationFormValues) => void;
@ -106,7 +124,7 @@ const defaultValuesForType = {
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => { export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({ const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || { defaultValues: initialValues || {
name: "", name: "",
}, },

View file

@ -1,10 +1,9 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { z } from "zod";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Form, Form,
@ -21,7 +20,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic"; import {
COMPRESSION_MODES,
azureRepositoryConfigSchema,
gcsRepositoryConfigSchema,
localRepositoryConfigSchema,
r2RepositoryConfigSchema,
rcloneRepositoryConfigSchema,
restRepositoryConfigSchema,
s3RepositoryConfigSchema,
sftpRepositoryConfigSchema,
} from "~/schemas/restic";
import { Checkbox } from "../../../components/ui/checkbox"; import { Checkbox } from "../../../components/ui/checkbox";
import { import {
LocalRepositoryForm, LocalRepositoryForm,
@ -38,13 +47,23 @@ import { useServerFn } from "@tanstack/react-start";
import { getServerConstants } from "~/server/lib/functions/server-constants"; import { getServerConstants } from "~/server/lib/functions/server-constants";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
export const formSchema = type({ const formBaseFields = {
name: "2<=string<=32", name: z.string().min(2).max(32),
compressionMode: type.valueOf(COMPRESSION_MODES).optional(), compressionMode: z.enum(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchemaBase); };
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn; export const formSchema = z.discriminatedUnion("backend", [
localRepositoryConfigSchema.extend(formBaseFields),
s3RepositoryConfigSchema.extend(formBaseFields),
r2RepositoryConfigSchema.extend(formBaseFields),
gcsRepositoryConfigSchema.extend(formBaseFields),
azureRepositoryConfigSchema.extend(formBaseFields),
rcloneRepositoryConfigSchema.extend(formBaseFields),
restRepositoryConfigSchema.extend(formBaseFields),
sftpRepositoryConfigSchema.extend(formBaseFields),
]);
export type RepositoryFormValues = z.input<typeof formSchema>;
type Props = { type Props = {
onSubmit: (values: RepositoryFormValues) => void; onSubmit: (values: RepositoryFormValues) => void;
@ -81,7 +100,7 @@ export const CreateRepositoryForm = ({
}); });
const form = useForm<RepositoryFormValues>({ const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues, defaultValues: initialValues,
resetOptions: { resetOptions: {
keepDefaultValues: true, keepDefaultValues: true,

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { import {
CreateRepositoryForm, CreateRepositoryForm,
formSchema,
type RepositoryFormValues, type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form"; } from "~/client/modules/repositories/components/create-repository-form";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -26,11 +27,13 @@ export function CreateRepositoryPage() {
}); });
const handleSubmit = (values: RepositoryFormValues) => { const handleSubmit = (values: RepositoryFormValues) => {
const { name, compressionMode, ...config } = formSchema.parse(values);
createRepository.mutate({ createRepository.mutate({
body: { body: {
config: values, config,
name: values.name, name,
compressionMode: values.compressionMode, compressionMode,
}, },
}); });
}; };

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { import {
CreateRepositoryForm, CreateRepositoryForm,
formSchema,
type RepositoryFormValues, type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form"; } from "~/client/modules/repositories/components/create-repository-form";
import { import {
@ -35,11 +36,11 @@ const riskyLocationFieldsByBackend = {
rclone: ["remote", "path"], rclone: ["remote", "path"],
} as const; } as const;
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => { const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryConfig): boolean => {
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? []; const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
return fields.some( return fields.some(
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryFormValues], (field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryConfig],
); );
}; };
@ -75,18 +76,20 @@ export function EditRepositoryPage({ repositoryId }: { repositoryId: string }) {
}; };
const submitUpdate = (values: RepositoryFormValues) => { const submitUpdate = (values: RepositoryFormValues) => {
const { name, compressionMode, ...config } = formSchema.parse(values);
updateRepository.mutate({ updateRepository.mutate({
path: { shortId: repositoryId }, path: { shortId: repositoryId },
body: { body: {
name: values.name, name,
compressionMode: values.compressionMode, compressionMode,
config: values, config,
}, },
}); });
}; };
const handleSubmit = (values: RepositoryFormValues) => { const handleSubmit = (values: RepositoryFormValues) => {
const nextConfig = values; const { name: _name, compressionMode: _compressionMode, ...nextConfig } = formSchema.parse(values);
if (hasRiskyLocationChange(initialConfig, nextConfig)) { if (hasRiskyLocationChange(initialConfig, nextConfig)) {
setPendingValues(values); setPendingValues(values);

View file

@ -1,10 +1,10 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { Plus, UserPlus } from "lucide-react"; import { Plus, UserPlus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { import {
Dialog, Dialog,
@ -20,15 +20,15 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
const createUserSchema = type({ const createUserSchema = z.object({
name: "string>=1", name: z.string().min(1),
username: "string>=1", username: z.string().min(1),
email: "string", email: z.string().email(),
password: "string>=8", password: z.string().min(8),
role: "'user'|'admin'", role: z.enum(["user", "admin"]),
}); });
type CreateUserFormValues = typeof createUserSchema.infer; type CreateUserFormValues = z.infer<typeof createUserSchema>;
interface CreateUserDialogProps { interface CreateUserDialogProps {
onUserCreated?: () => void; onUserCreated?: () => void;
@ -38,7 +38,7 @@ export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const form = useForm<CreateUserFormValues>({ const form = useForm<CreateUserFormValues>({
resolver: arktypeResolver(createUserSchema), resolver: zodResolver(createUserSchema),
defaultValues: { defaultValues: {
name: "", name: "",
username: "", username: "",

View file

@ -1,11 +1,11 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { type } from "arktype";
import { ShieldCheck, Plus } from "lucide-react"; import { ShieldCheck, Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
@ -24,17 +24,17 @@ import { authClient } from "~/client/lib/auth-client";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { useOrganizationContext } from "~/client/hooks/use-org-context"; import { useOrganizationContext } from "~/client/hooks/use-org-context";
const ssoProviderSchema = type({ const ssoProviderSchema = z.object({
providerId: "string>=1", providerId: z.string().min(1),
issuer: "string>=1", issuer: z.string().min(1),
domain: "string>=1", domain: z.string().min(1),
clientId: "string>=1", clientId: z.string().min(1),
clientSecret: "string>=1", clientSecret: z.string().min(1),
discoveryEndpoint: "string>=1", discoveryEndpoint: z.string().min(1),
linkMatchingEmails: "boolean", linkMatchingEmails: z.boolean(),
}); });
type ProviderForm = typeof ssoProviderSchema.infer; type ProviderForm = z.infer<typeof ssoProviderSchema>;
export function CreateSsoProviderPage() { export function CreateSsoProviderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -42,7 +42,7 @@ export function CreateSsoProviderPage() {
const { activeOrganization } = useOrganizationContext(); const { activeOrganization } = useOrganizationContext();
const form = useForm<ProviderForm>({ const form = useForm<ProviderForm>({
resolver: arktypeResolver(ssoProviderSchema), resolver: zodResolver(ssoProviderSchema),
defaultValues: { defaultValues: {
providerId: "", providerId: "",
issuer: "", issuer: "",

View file

@ -1,11 +1,10 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react"; import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Form, Form,
@ -18,19 +17,31 @@ import {
} from "../../../components/ui/form"; } from "../../../components/ui/form";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { volumeConfigSchemaBase } from "~/schemas/volumes"; import {
directoryConfigSchema,
nfsConfigSchema,
rcloneConfigSchema,
sftpConfigSchema,
smbConfigSchema,
volumeConfigSchema,
webdavConfigSchema,
} from "~/schemas/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms"; import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
export const formSchema = type({ export const formSchema = z.discriminatedUnion("backend", [
name: "2<=string<=32", directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
}).and(volumeConfigSchemaBase); nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); smbConfigSchema.extend({ name: z.string().min(2).max(32) }),
webdavConfigSchema.extend({ name: z.string().min(2).max(32) }),
rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }),
sftpConfigSchema.extend({ name: z.string().min(2).max(32) }),
]);
export type FormValues = typeof formSchema.inferIn; export type FormValues = z.input<typeof formSchema>;
type Props = { type Props = {
onSubmit: (values: FormValues) => void; onSubmit: (values: FormValues) => void;
@ -52,7 +63,7 @@ const defaultValuesForType = {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || { defaultValues: initialValues || {
name: "", name: "",
backend: "directory", backend: "directory",
@ -89,15 +100,22 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const handleTestConnection = async () => { const handleTestConnection = async () => {
const formValues = getValues(); const formValues = getValues();
const { name: _, ...configCandidate } = formValues;
const parsedConfig = volumeConfigSchema.safeParse(configCandidate);
if (!parsedConfig.success) {
setTestMessage({ success: false, message: "Please fix validation errors before testing the connection." });
return;
}
if ( if (
formValues.backend === "nfs" || parsedConfig.data.backend === "nfs" ||
formValues.backend === "smb" || parsedConfig.data.backend === "smb" ||
formValues.backend === "webdav" || parsedConfig.data.backend === "webdav" ||
formValues.backend === "sftp" parsedConfig.data.backend === "sftp"
) { ) {
testBackendConnection.mutate({ testBackendConnection.mutate({
body: { config: formValues }, body: { config: parsedConfig.data },
}); });
} }
}; };

View file

@ -3,7 +3,7 @@ import { HardDrive, Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
@ -23,10 +23,12 @@ export function CreateVolumePage() {
}); });
const handleSubmit = (values: FormValues) => { const handleSubmit = (values: FormValues) => {
const { name, ...config } = formSchema.parse(values);
createVolume.mutate({ createVolume.mutate({
body: { body: {
config: values, config,
name: values.name, name,
}, },
}); });
}; };

View file

@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Check, Plug, Trash2, Unplug } from "lucide-react"; import { Check, Plug, Trash2, Unplug } from "lucide-react";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -102,9 +102,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const confirmUpdate = () => { const confirmUpdate = () => {
if (pendingValues) { if (pendingValues) {
const { name, ...config } = formSchema.parse(pendingValues);
updateMutation.mutate({ updateMutation.mutate({
path: { shortId: volume.shortId }, path: { shortId: volume.shortId },
body: { name: pendingValues.name, config: pendingValues }, body: { name, config },
}); });
} }
}; };

View file

@ -1,11 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { LoginPage } from "~/client/modules/auth/routes/login"; import { LoginPage } from "~/client/modules/auth/routes/login";
export const Route = createFileRoute("/(auth)/login/error")({ export const Route = createFileRoute("/(auth)/login/error")({
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load login error</div>, errorComponent: () => <div>Failed to load login error</div>,
validateSearch: type({ error: "string?" }), validateSearch: z.object({ error: z.string().optional() }),
head: () => ({ head: () => ({
meta: [ meta: [
{ title: "Zerobyte - Login Error" }, { title: "Zerobyte - Login Error" },

View file

@ -1,11 +1,11 @@
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router"; import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { LoginPage } from "~/client/modules/auth/routes/login"; import { LoginPage } from "~/client/modules/auth/routes/login";
export const Route = createFileRoute("/(auth)/login")({ export const Route = createFileRoute("/(auth)/login")({
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load login</div>, errorComponent: () => <div>Failed to load login</div>,
validateSearch: type({ error: "string?" }), validateSearch: z.object({ error: z.string().optional() }),
head: () => ({ head: () => ({
meta: [ meta: [
{ title: "Zerobyte - Login" }, { title: "Zerobyte - Login" },

View file

@ -1,12 +1,12 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { fetchUser } from "../route"; import { fetchUser } from "../route";
import type { AppContext } from "~/context"; import type { AppContext } from "~/context";
import { AdminPage } from "~/client/modules/admin/routes/admin-page"; import { AdminPage } from "~/client/modules/admin/routes/admin-page";
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const Route = createFileRoute("/(dashboard)/admin/")({ export const Route = createFileRoute("/(dashboard)/admin/")({
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load admin</div>, errorComponent: () => <div>Failed to load admin</div>,
loader: async ({ context }) => { loader: async ({ context }) => {

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { import {
getBackupProgressOptions, getBackupProgressOptions,
getBackupScheduleOptions, getBackupScheduleOptions,
@ -15,7 +15,7 @@ import { prefetchOrSkip } from "~/utils/prefetch";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load backup</div>, errorComponent: () => <div>Failed to load backup</div>,
validateSearch: type({ snapshot: "string?" }), validateSearch: z.object({ snapshot: z.string().optional() }),
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
const { backupId } = params; const { backupId } = params;

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { import {
getRepositoryOptions, getRepositoryOptions,
getRepositoryStatsOptions, getRepositoryStatsOptions,
@ -30,7 +30,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")
stats: context.queryClient.getQueryData(statsOptions.queryKey), stats: context.queryClient.getQueryData(statsOptions.queryKey),
}; };
}, },
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
staticData: { staticData: {
breadcrumb: (match) => [ breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" }, { label: "Repositories", href: "/repositories" },

View file

@ -2,14 +2,14 @@ import { createFileRoute } from "@tanstack/react-router";
import { fetchUser } from "../route"; import { fetchUser } from "../route";
import type { AppContext } from "~/context"; import type { AppContext } from "~/context";
import { SettingsPage } from "~/client/modules/settings/routes/settings"; import { SettingsPage } from "~/client/modules/settings/routes/settings";
import { type } from "arktype"; import { z } from "zod";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { getOrigin } from "~/client/functions/get-origin"; import { getOrigin } from "~/client/functions/get-origin";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
export const Route = createFileRoute("/(dashboard)/settings/")({ export const Route = createFileRoute("/(dashboard)/settings/")({
component: RouteComponent, component: RouteComponent,
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
errorComponent: () => <div>Failed to load settings</div>, errorComponent: () => <div>Failed to load settings</div>,
loader: async ({ context }) => { loader: async ({ context }) => {
const [authContext, orgContext] = await Promise.all([ const [authContext, orgContext] = await Promise.all([

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details"; import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
@ -13,7 +13,7 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
return res; return res;
}, },
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
staticData: { staticData: {
breadcrumb: (match) => [ breadcrumb: (match) => [
{ label: "Volumes", href: "/volumes" }, { label: "Volumes", href: "/volumes" },

View file

@ -1,85 +1,84 @@
import { type } from "arktype"; import { z } from "zod";
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto"; import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'"); export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
export const restoreEventStatusSchema = type("'success' | 'error'"); export const restoreEventStatusSchema = z.enum(["success", "error"]);
const backupEventBaseSchema = type({ const backupEventBaseSchema = z.object({
scheduleId: "string", scheduleId: z.string(),
volumeName: "string", volumeName: z.string(),
repositoryName: "string", repositoryName: z.string(),
}); });
const organizationScopedSchema = type({ const organizationScopedSchema = z.object({
organizationId: "string", organizationId: z.string(),
}); });
const restoreEventBaseSchema = type({ const restoreEventBaseSchema = z.object({
repositoryId: "string", repositoryId: z.string(),
snapshotId: "string", snapshotId: z.string(),
}); });
const dumpStartedEventSchema = type({ const dumpStartedEventSchema = z.object({
repositoryId: "string", repositoryId: z.string(),
snapshotId: "string", snapshotId: z.string(),
path: "string", path: z.string(),
filename: "string", filename: z.string(),
}); });
const restoreProgressMetricsSchema = type({ const restoreProgressMetricsSchema = z.object({
seconds_elapsed: "number", seconds_elapsed: z.number(),
percent_done: "number", percent_done: z.number(),
total_files: "number", total_files: z.number(),
files_restored: "number", files_restored: z.number(),
total_bytes: "number", total_bytes: z.number(),
bytes_restored: "number", bytes_restored: z.number(),
}); });
export const backupStartedEventSchema = backupEventBaseSchema; export const backupStartedEventSchema = backupEventBaseSchema;
export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema); export const backupProgressEventSchema = backupEventBaseSchema.extend(resticBackupProgressMetricsSchema.shape);
export const backupCompletedEventSchema = backupEventBaseSchema.and( export const backupCompletedEventSchema = backupEventBaseSchema.extend({
type({ status: backupEventStatusSchema,
status: backupEventStatusSchema, summary: resticBackupRunSummarySchema.optional(),
summary: resticBackupRunSummarySchema.optional(), });
}),
);
export const restoreStartedEventSchema = restoreEventBaseSchema; export const restoreStartedEventSchema = restoreEventBaseSchema;
export const restoreProgressEventSchema = restoreEventBaseSchema.and(restoreProgressMetricsSchema); export const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgressMetricsSchema.shape);
export const restoreCompletedEventSchema = restoreEventBaseSchema.and( export const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
type({ status: restoreEventStatusSchema, error: "string?" }), status: restoreEventStatusSchema,
); error: z.string().optional(),
});
export const serverBackupStartedEventSchema = organizationScopedSchema.and(backupStartedEventSchema); export const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
export const serverBackupProgressEventSchema = organizationScopedSchema.and(backupProgressEventSchema); export const serverBackupProgressEventSchema = organizationScopedSchema.extend(backupProgressEventSchema.shape);
export const serverBackupCompletedEventSchema = organizationScopedSchema.and(backupCompletedEventSchema); export const serverBackupCompletedEventSchema = organizationScopedSchema.extend(backupCompletedEventSchema.shape);
export const serverRestoreStartedEventSchema = organizationScopedSchema.and(restoreStartedEventSchema); export const serverRestoreStartedEventSchema = organizationScopedSchema.extend(restoreStartedEventSchema.shape);
export const serverRestoreProgressEventSchema = organizationScopedSchema.and(restoreProgressEventSchema); export const serverRestoreProgressEventSchema = organizationScopedSchema.extend(restoreProgressEventSchema.shape);
export const serverRestoreCompletedEventSchema = organizationScopedSchema.and(restoreCompletedEventSchema); export const serverRestoreCompletedEventSchema = organizationScopedSchema.extend(restoreCompletedEventSchema.shape);
export const serverDumpStartedEventSchema = organizationScopedSchema.and(dumpStartedEventSchema); export const serverDumpStartedEventSchema = organizationScopedSchema.extend(dumpStartedEventSchema.shape);
export type BackupEventStatusDto = typeof backupEventStatusSchema.infer; export type BackupEventStatusDto = z.infer<typeof backupEventStatusSchema>;
export type BackupStartedEventDto = typeof backupStartedEventSchema.infer; export type BackupStartedEventDto = z.infer<typeof backupStartedEventSchema>;
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer; export type BackupProgressEventDto = z.infer<typeof backupProgressEventSchema>;
export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer; export type BackupCompletedEventDto = z.infer<typeof backupCompletedEventSchema>;
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer; export type RestoreStartedEventDto = z.infer<typeof restoreStartedEventSchema>;
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer; export type RestoreProgressEventDto = z.infer<typeof restoreProgressEventSchema>;
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer; export type RestoreCompletedEventDto = z.infer<typeof restoreCompletedEventSchema>;
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer; export type DumpStartedEventDto = z.infer<typeof dumpStartedEventSchema>;
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer; export type ServerBackupStartedEventDto = z.infer<typeof serverBackupStartedEventSchema>;
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer; export type ServerBackupProgressEventDto = z.infer<typeof serverBackupProgressEventSchema>;
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer; export type ServerBackupCompletedEventDto = z.infer<typeof serverBackupCompletedEventSchema>;
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer; export type ServerRestoreStartedEventDto = z.infer<typeof serverRestoreStartedEventSchema>;
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer; export type ServerRestoreProgressEventDto = z.infer<typeof serverRestoreProgressEventSchema>;
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer; export type ServerRestoreCompletedEventDto = z.infer<typeof serverRestoreCompletedEventSchema>;
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer; export type ServerDumpStartedEventDto = z.infer<typeof serverDumpStartedEventSchema>;

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
export const NOTIFICATION_TYPES = { export const NOTIFICATION_TYPES = {
email: "email", email: "email",
@ -14,96 +14,98 @@ export const NOTIFICATION_TYPES = {
export type NotificationType = keyof typeof NOTIFICATION_TYPES; export type NotificationType = keyof typeof NOTIFICATION_TYPES;
export const emailNotificationConfigSchema = type({ export const emailNotificationConfigSchema = z.object({
type: "'email'", type: z.literal("email"),
smtpHost: "string", smtpHost: z.string().min(1),
smtpPort: "1 <= number <= 65535", smtpPort: z.number().int().min(1).max(65535),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
from: "string", from: z.string().min(1),
fromName: "string?", fromName: z.string().optional(),
to: "string[]", to: z.array(z.string()),
useTLS: "boolean", useTLS: z.boolean(),
}); });
export const slackNotificationConfigSchema = type({ export const slackNotificationConfigSchema = z.object({
type: "'slack'", type: z.literal("slack"),
webhookUrl: "string", webhookUrl: z.string().min(1),
channel: "string?", channel: z.string().optional(),
username: "string?", username: z.string().optional(),
iconEmoji: "string?", iconEmoji: z.string().optional(),
}); });
export const discordNotificationConfigSchema = type({ export const discordNotificationConfigSchema = z.object({
type: "'discord'", type: z.literal("discord"),
webhookUrl: "string", webhookUrl: z.string().min(1),
username: "string?", username: z.string().optional(),
avatarUrl: "string?", avatarUrl: z.string().optional(),
threadId: "string?", threadId: z.string().optional(),
}); });
export const gotifyNotificationConfigSchema = type({ export const gotifyNotificationConfigSchema = z.object({
type: "'gotify'", type: z.literal("gotify"),
serverUrl: "string", serverUrl: z.string().min(1),
token: "string", token: z.string().min(1),
path: "string?", path: z.string().optional(),
priority: "0 <= number <= 10", priority: z.number().min(0).max(10),
}); });
export const ntfyNotificationConfigSchema = type({ export const ntfyNotificationConfigSchema = z.object({
type: "'ntfy'", type: z.literal("ntfy"),
serverUrl: "string?", serverUrl: z.string().optional(),
topic: "string", topic: z.string().min(1),
priority: "'max' | 'high' | 'default' | 'low' | 'min'", priority: z.enum(["max", "high", "default", "low", "min"]),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
accessToken: "string?", accessToken: z.string().optional(),
}); });
export const pushoverNotificationConfigSchema = type({ export const pushoverNotificationConfigSchema = z.object({
type: "'pushover'", type: z.literal("pushover"),
userKey: "string", userKey: z.string().min(1),
apiToken: "string", apiToken: z.string().min(1),
devices: "string?", devices: z.string().optional(),
priority: "-1 | 0 | 1", priority: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
}); });
export const telegramNotificationConfigSchema = type({ export const telegramNotificationConfigSchema = z.object({
type: "'telegram'", type: z.literal("telegram"),
botToken: "string", botToken: z.string().min(1),
chatId: "string", chatId: z.string().min(1),
threadId: "string?", threadId: z.string().optional(),
}); });
export const genericNotificationConfigSchema = type({ export const genericNotificationConfigSchema = z.object({
type: "'generic'", type: z.literal("generic"),
url: "string", url: z.string().min(1),
method: "'GET' | 'POST'", method: z.enum(["GET", "POST"]),
contentType: "string?", contentType: z.string().optional(),
headers: "string[]?", headers: z.array(z.string()).optional(),
useJson: "boolean?", useJson: z.boolean().optional(),
titleKey: "string?", titleKey: z.string().optional(),
messageKey: "string?", messageKey: z.string().optional(),
}); });
export const customNotificationConfigSchema = type({ export const customNotificationConfigSchema = z.object({
type: "'custom'", type: z.literal("custom"),
shoutrrrUrl: "string", shoutrrrUrl: z.string().min(1),
}); });
export const notificationConfigSchemaBase = emailNotificationConfigSchema export const notificationConfigSchemaBase = z.discriminatedUnion("type", [
.or(slackNotificationConfigSchema) emailNotificationConfigSchema,
.or(discordNotificationConfigSchema) slackNotificationConfigSchema,
.or(gotifyNotificationConfigSchema) discordNotificationConfigSchema,
.or(ntfyNotificationConfigSchema) gotifyNotificationConfigSchema,
.or(pushoverNotificationConfigSchema) ntfyNotificationConfigSchema,
.or(telegramNotificationConfigSchema) pushoverNotificationConfigSchema,
.or(genericNotificationConfigSchema) telegramNotificationConfigSchema,
.or(customNotificationConfigSchema); genericNotificationConfigSchema,
customNotificationConfigSchema,
]);
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete"); export const notificationConfigSchema = notificationConfigSchemaBase;
export type NotificationConfig = typeof notificationConfigSchema.infer; export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
export const NOTIFICATION_EVENTS = { export const NOTIFICATION_EVENTS = {
start: "start", start: "start",

View file

@ -1,83 +1,72 @@
import { type } from "arktype"; import { z } from "zod";
export const resticSummaryBaseSchema = type({ export const resticSummaryBaseSchema = z.object({
files_new: "number", files_new: z.number(),
files_changed: "number", files_changed: z.number(),
files_unmodified: "number", files_unmodified: z.number(),
dirs_new: "number", dirs_new: z.number(),
dirs_changed: "number", dirs_changed: z.number(),
dirs_unmodified: "number", dirs_unmodified: z.number(),
data_blobs: "number", data_blobs: z.number(),
tree_blobs: "number", tree_blobs: z.number(),
data_added: "number", data_added: z.number(),
data_added_packed: "number?", data_added_packed: z.number().optional(),
total_files_processed: "number", total_files_processed: z.number(),
total_bytes_processed: "number", total_bytes_processed: z.number(),
}); });
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and( export const resticSnapshotSummarySchema = resticSummaryBaseSchema.extend({
type({ backup_start: z.string(),
backup_start: "string", backup_end: z.string(),
backup_end: "string",
}),
);
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.and(
type({
total_duration: "number",
snapshot_id: "string",
}),
);
export const resticBackupOutputSchema = resticBackupRunSummarySchema.and(
type({
message_type: "'summary'",
}),
);
export const resticBackupProgressMetricsSchema = type({
seconds_elapsed: "number",
seconds_remaining: "number = 0",
percent_done: "number",
total_files: "number",
files_done: "number",
total_bytes: "number",
bytes_done: "number",
current_files: type("string")
.array()
.default(() => []),
}); });
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and( export const resticBackupRunSummarySchema = resticSummaryBaseSchema.extend({
type({ total_duration: z.number(),
message_type: "'status'", snapshot_id: z.string(),
}),
);
export const resticRestoreOutputSchema = type({
message_type: "'summary'",
total_files: "number?",
files_restored: "number",
files_skipped: "number",
total_bytes: "number?",
bytes_restored: "number?",
bytes_skipped: "number",
}); });
export const resticStatsSchema = type({ export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
total_size: "number = 0", message_type: z.literal("summary"),
total_uncompressed_size: "number = 0",
compression_ratio: "number = 0",
compression_progress: "number = 0",
compression_space_saving: "number = 0",
snapshots_count: "number = 0",
}); });
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer; export const resticBackupProgressMetricsSchema = z.object({
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer; seconds_elapsed: z.number(),
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer; percent_done: z.number(),
export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer; total_files: z.number(),
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer; files_done: z.number(),
total_bytes: z.number(),
bytes_done: z.number(),
current_files: z.array(z.string()).default([]),
});
export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer; export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.extend({
export type ResticStatsDto = typeof resticStatsSchema.infer; message_type: z.literal("status"),
});
export const resticRestoreOutputSchema = z.object({
message_type: z.literal("summary"),
total_files: z.number().optional(),
files_restored: z.number(),
files_skipped: z.number(),
total_bytes: z.number().optional(),
bytes_restored: z.number().optional(),
bytes_skipped: z.number(),
});
export const resticStatsSchema = z.object({
total_size: z.number().default(0),
total_uncompressed_size: z.number().default(0),
compression_ratio: z.number().default(0),
compression_progress: z.number().default(0),
compression_space_saving: z.number().default(0),
snapshots_count: z.number().default(0),
});
export type ResticSnapshotSummaryDto = z.infer<typeof resticSnapshotSummarySchema>;
export type ResticBackupRunSummaryDto = z.infer<typeof resticBackupRunSummarySchema>;
export type ResticBackupOutputDto = z.infer<typeof resticBackupOutputSchema>;
export type ResticBackupProgressMetricsDto = z.infer<typeof resticBackupProgressMetricsSchema>;
export type ResticBackupProgressDto = z.infer<typeof resticBackupProgressSchema>;
export type ResticRestoreOutputDto = z.infer<typeof resticRestoreOutputSchema>;
export type ResticStatsDto = z.infer<typeof resticStatsSchema>;

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
export const REPOSITORY_BACKENDS = { export const REPOSITORY_BACKENDS = {
local: "local", local: "local",
@ -21,98 +21,120 @@ export const BANDWIDTH_UNITS = {
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS; export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
export const bandwidthLimitSchema = type({ const bandwidthUnitSchema = z.enum(["Kbps", "Mbps", "Gbps"]);
enabled: "boolean = false",
value: "number > 0 = 1", export const bandwidthLimitSchema = z.object({
unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"), enabled: z.boolean().default(false),
value: z.number().positive().default(1),
unit: bandwidthUnitSchema.default("Mbps"),
}); });
export type BandwidthLimit = typeof bandwidthLimitSchema.infer; export type BandwidthLimit = z.infer<typeof bandwidthLimitSchema>;
// Common fields for all repository configs const baseRepositoryConfigSchema = z.object({
const baseRepositoryConfigSchema = type({ isExistingRepository: z.boolean().optional(),
isExistingRepository: "boolean?", customPassword: z.string().optional(),
customPassword: "string?", cacert: z.string().optional(),
cacert: "string?", insecureTls: z.boolean().optional(),
insecureTls: "boolean?",
// Bandwidth controls
uploadLimit: bandwidthLimitSchema.optional(), uploadLimit: bandwidthLimitSchema.optional(),
downloadLimit: bandwidthLimitSchema.optional(), downloadLimit: bandwidthLimitSchema.optional(),
}); });
export const s3RepositoryConfigSchema = type({ export const s3RepositoryConfigSchema = z
backend: "'s3'", .object({
endpoint: "string", backend: z.literal("s3"),
bucket: "string", endpoint: z.string().min(1),
accessKeyId: "string", bucket: z.string().min(1),
secretAccessKey: "string", accessKeyId: z.string().min(1),
}).and(baseRepositoryConfigSchema); secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const r2RepositoryConfigSchema = type({ export const r2RepositoryConfigSchema = z
backend: "'r2'", .object({
endpoint: "string", backend: z.literal("r2"),
bucket: "string", endpoint: z.string().min(1),
accessKeyId: "string", bucket: z.string().min(1),
secretAccessKey: "string", accessKeyId: z.string().min(1),
}).and(baseRepositoryConfigSchema); secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const localRepositoryConfigSchema = type({ export const localRepositoryConfigSchema = z
backend: "'local'", .object({
path: "string", backend: z.literal("local"),
}).and(baseRepositoryConfigSchema); path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const gcsRepositoryConfigSchema = type({ export const gcsRepositoryConfigSchema = z
backend: "'gcs'", .object({
bucket: "string", backend: z.literal("gcs"),
projectId: "string", bucket: z.string().min(1),
credentialsJson: "string", projectId: z.string().min(1),
}).and(baseRepositoryConfigSchema); credentialsJson: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const azureRepositoryConfigSchema = type({ export const azureRepositoryConfigSchema = z
backend: "'azure'", .object({
container: "string", backend: z.literal("azure"),
accountName: "string", container: z.string().min(1),
accountKey: "string", accountName: z.string().min(1),
endpointSuffix: "string?", accountKey: z.string().min(1),
}).and(baseRepositoryConfigSchema); endpointSuffix: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const rcloneRepositoryConfigSchema = type({ export const rcloneRepositoryConfigSchema = z
backend: "'rclone'", .object({
remote: "string", backend: z.literal("rclone"),
path: "string", remote: z.string().min(1),
}).and(baseRepositoryConfigSchema); path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const restRepositoryConfigSchema = type({ export const restRepositoryConfigSchema = z
backend: "'rest'", .object({
url: "string", backend: z.literal("rest"),
username: "string?", url: z.string().min(1),
password: "string?", username: z.string().optional(),
path: "string?", password: z.string().optional(),
}).and(baseRepositoryConfigSchema); path: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const sftpRepositoryConfigSchema = type({ export const sftpRepositoryConfigSchema = z
backend: "'sftp'", .object({
host: "string", backend: z.literal("sftp"),
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22), host: z.string().min(1),
user: "string", port: z
path: "string", .union([z.string(), z.number()])
privateKey: "string", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
skipHostKeyCheck: "boolean = true", .pipe(z.number().int().min(1).max(65535))
knownHosts: "string?", .default(22),
}).and(baseRepositoryConfigSchema); user: z.string().min(1),
path: z.string().min(1),
privateKey: z.string().min(1),
skipHostKeyCheck: z.boolean().default(true),
knownHosts: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const repositoryConfigSchemaBase = s3RepositoryConfigSchema export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [
.or(r2RepositoryConfigSchema) s3RepositoryConfigSchema,
.or(localRepositoryConfigSchema) r2RepositoryConfigSchema,
.or(gcsRepositoryConfigSchema) localRepositoryConfigSchema,
.or(azureRepositoryConfigSchema) gcsRepositoryConfigSchema,
.or(rcloneRepositoryConfigSchema) azureRepositoryConfigSchema,
.or(restRepositoryConfigSchema) rcloneRepositoryConfigSchema,
.or(sftpRepositoryConfigSchema); restRepositoryConfigSchema,
sftpRepositoryConfigSchema,
]);
export const repositoryConfigSchema = repositoryConfigSchemaBase.onUndeclaredKey("delete"); export const repositoryConfigSchema = repositoryConfigSchemaBase;
export type RepositoryConfig = typeof repositoryConfigSchema.infer; export type RepositoryConfig = z.infer<typeof repositoryConfigSchema>;
export const COMPRESSION_MODES = { export const COMPRESSION_MODES = {
off: "off", off: "off",
@ -132,22 +154,22 @@ export const REPOSITORY_STATUS = {
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS; export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
export const doctorStepSchema = type({ export const doctorStepSchema = z.object({
step: "string", step: z.string(),
success: "boolean", success: z.boolean(),
output: "string | null", output: z.string().nullable(),
error: "string | null", error: z.string().nullable(),
}); });
export type DoctorStep = typeof doctorStepSchema.infer; export type DoctorStep = z.infer<typeof doctorStepSchema>;
export const doctorResultSchema = type({ export const doctorResultSchema = z.object({
success: "boolean", success: z.boolean(),
steps: doctorStepSchema.array(), steps: doctorStepSchema.array(),
completedAt: "number", completedAt: z.number(),
}); });
export type DoctorResult = typeof doctorResultSchema.infer; export type DoctorResult = z.infer<typeof doctorResultSchema>;
export const OVERWRITE_MODES = { export const OVERWRITE_MODES = {
always: "always", always: "always",

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
export const BACKEND_TYPES = { export const BACKEND_TYPES = {
nfs: "nfs", nfs: "nfs",
@ -11,75 +11,93 @@ export const BACKEND_TYPES = {
export type BackendType = keyof typeof BACKEND_TYPES; export type BackendType = keyof typeof BACKEND_TYPES;
export const nfsConfigSchema = type({ export const nfsConfigSchema = z.object({
backend: "'nfs'", backend: z.literal("nfs"),
server: "string", server: z.string().min(1),
exportPath: "string", exportPath: z.string().min(1),
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049), port: z
version: "'3' | '4' | '4.1'", .union([z.string(), z.number()])
readOnly: "boolean?", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(2049),
version: z.enum(["3", "4", "4.1"]),
readOnly: z.boolean().optional(),
}); });
export const smbConfigSchema = type({ export const smbConfigSchema = z.object({
backend: "'smb'", backend: z.literal("smb"),
server: "string", server: z.string().min(1),
share: "string", share: z.string().min(1),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
guest: "boolean?", guest: z.boolean().optional(),
vers: type("'1.0' | '2.0' | '2.1' | '3.0' | 'auto'").default("auto"), vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
domain: "string?", domain: z.string().optional(),
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445), port: z
readOnly: "boolean?", .union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(445),
readOnly: z.boolean().optional(),
}); });
export const directoryConfigSchema = type({ export const directoryConfigSchema = z.object({
backend: "'directory'", backend: z.literal("directory"),
path: "string", path: z.string().min(1),
readOnly: "false?", readOnly: z.literal(false).optional(),
}); });
export const webdavConfigSchema = type({ export const webdavConfigSchema = z.object({
backend: "'webdav'", backend: z.literal("webdav"),
server: "string", server: z.string().min(1),
path: "string", path: z.string().min(1),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80), port: z
readOnly: "boolean?", .union([z.string(), z.number()])
ssl: "boolean?", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(80),
readOnly: z.boolean().optional(),
ssl: z.boolean().optional(),
}); });
export const rcloneConfigSchema = type({ export const rcloneConfigSchema = z.object({
backend: "'rclone'", backend: z.literal("rclone"),
remote: "string", remote: z.string().min(1),
path: "string", path: z.string().min(1),
readOnly: "boolean?", readOnly: z.boolean().optional(),
}); });
export const sftpConfigSchema = type({ export const sftpConfigSchema = z.object({
backend: "'sftp'", backend: z.literal("sftp"),
host: "string", host: z.string().min(1),
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22), port: z
username: "string", .union([z.string(), z.number()])
password: "string?", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
privateKey: "string?", .pipe(z.number().int().min(1).max(65535))
path: "string", .default(22),
readOnly: "boolean?", username: z.string().min(1),
skipHostKeyCheck: "boolean = true", password: z.string().optional(),
knownHosts: "string?", privateKey: z.string().optional(),
path: z.string().min(1),
readOnly: z.boolean().optional(),
skipHostKeyCheck: z.boolean().default(true),
knownHosts: z.string().optional(),
}); });
export const volumeConfigSchemaBase = nfsConfigSchema export const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
.or(smbConfigSchema) nfsConfigSchema,
.or(webdavConfigSchema) smbConfigSchema,
.or(directoryConfigSchema) webdavConfigSchema,
.or(rcloneConfigSchema) directoryConfigSchema,
.or(sftpConfigSchema); rcloneConfigSchema,
sftpConfigSchema,
]);
export const volumeConfigSchema = volumeConfigSchemaBase.onUndeclaredKey("delete"); export const volumeConfigSchema = volumeConfigSchemaBase;
export type BackendConfig = typeof volumeConfigSchema.infer; export type BackendConfig = z.infer<typeof volumeConfigSchema>;
export const BACKEND_STATUS = { export const BACKEND_STATUS = {
mounted: "mounted", mounted: "mounted",

View file

@ -1,6 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import { type } from "arktype"; import { z } from "zod";
import "dotenv/config"; import "dotenv/config";
const getResticHostname = () => { const getResticHostname = () => {
@ -24,43 +24,45 @@ const getResticHostname = () => {
return "zerobyte"; return "zerobyte";
}; };
const envSchema = type({ const envSchema = z
NODE_ENV: type.enumerated("development", "production", "test").default("production"), .object({
SERVER_IP: 'string = "localhost"', NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
SERVER_IDLE_TIMEOUT: 'string.integer.parse = "60"', SERVER_IP: z.string().default("localhost"),
RESTIC_HOSTNAME: "string?", SERVER_IDLE_TIMEOUT: z.coerce.number().int().default(60),
PORT: 'string.integer.parse = "4096"', RESTIC_HOSTNAME: z.string().optional(),
MIGRATIONS_PATH: "string?", PORT: z.coerce.number().int().default(4096),
APP_VERSION: "string = 'dev'", MIGRATIONS_PATH: z.string().optional(),
TRUSTED_ORIGINS: "string?", APP_VERSION: z.string().default("dev"),
DISABLE_RATE_LIMITING: 'string = "false"', TRUSTED_ORIGINS: z.string().optional(),
APP_SECRET: "32 <= string <= 256", DISABLE_RATE_LIMITING: z.string().default("false"),
BASE_URL: "string", APP_SECRET: z.string().min(32).max(256),
ENABLE_DEV_PANEL: 'string = "false"', BASE_URL: z.string(),
}).pipe((s) => ({ ENABLE_DEV_PANEL: z.string().default("false"),
__prod__: s.NODE_ENV === "production", })
environment: s.NODE_ENV, .transform((s) => ({
serverIp: s.SERVER_IP, __prod__: s.NODE_ENV === "production",
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT, environment: s.NODE_ENV,
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(), serverIp: s.SERVER_IP,
port: s.PORT, serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
migrationsPath: s.MIGRATIONS_PATH, resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
appVersion: s.APP_VERSION, port: s.PORT,
trustedOrigins: s.TRUSTED_ORIGINS?.split(",") migrationsPath: s.MIGRATIONS_PATH,
.map((origin) => origin.trim()) appVersion: s.APP_VERSION,
.filter(Boolean) trustedOrigins: s.TRUSTED_ORIGINS?.split(",")
.concat(s.BASE_URL) ?? [s.BASE_URL], .map((origin) => origin.trim())
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true", .filter(Boolean)
appSecret: s.APP_SECRET, .concat(s.BASE_URL) ?? [s.BASE_URL],
baseUrl: s.BASE_URL, disableRateLimiting: s.DISABLE_RATE_LIMITING === "true",
isSecure: s.BASE_URL?.startsWith("https://") ?? false, appSecret: s.APP_SECRET,
enableDevPanel: s.ENABLE_DEV_PANEL === "true", baseUrl: s.BASE_URL,
})); isSecure: s.BASE_URL?.startsWith("https://") ?? false,
enableDevPanel: s.ENABLE_DEV_PANEL === "true",
}));
const parseConfig = (env: unknown) => { const parseConfig = (env: unknown) => {
const result = envSchema(env); const result = envSchema.safeParse(env);
if (result instanceof type.errors) { if (!result.success) {
if (!process.env.APP_SECRET) { if (!process.env.APP_SECRET) {
const errorMessage = [ const errorMessage = [
"", "",
@ -82,11 +84,12 @@ const parseConfig = (env: unknown) => {
console.error(errorMessage); console.error(errorMessage);
} }
console.error(`Environment variable validation failed: ${result.summary}`);
console.error(`Environment variable validation failed: ${result.error.message}`);
throw new Error("Invalid environment variables"); throw new Error("Invalid environment variables");
} }
return result; return result.data;
}; };
export const config = parseConfig(process.env); export const config = parseConfig(process.env);

View file

@ -3,14 +3,14 @@ import { index, int, integer, sqliteTable, text, real, primaryKey, unique, uniqu
import type { import type {
CompressionMode, CompressionMode,
RepositoryBackend, RepositoryBackend,
repositoryConfigSchema, RepositoryConfig,
RepositoryStatus, RepositoryStatus,
BandwidthUnit, BandwidthUnit,
DoctorResult, DoctorResult,
} from "~/schemas/restic"; } from "~/schemas/restic";
import type { ResticStatsDto } from "~/schemas/restic-dto"; import type { ResticStatsDto } from "~/schemas/restic-dto";
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes"; import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications"; import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
import type { ShortId } from "~/server/utils/branded"; import type { ShortId } from "~/server/utils/branded";
/** /**
@ -217,7 +217,7 @@ export const volumesTable = sqliteTable(
.notNull() .notNull()
.$onUpdate(() => Date.now()) .$onUpdate(() => Date.now())
.default(sql`(unixepoch() * 1000)`), .default(sql`(unixepoch() * 1000)`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<BackendConfig>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
organizationId: text("organization_id") organizationId: text("organization_id")
.notNull() .notNull()
@ -236,7 +236,7 @@ export const repositoriesTable = sqliteTable("repositories_table", {
shortId: text("short_id").$type<ShortId>().notNull().unique(), shortId: text("short_id").$type<ShortId>().notNull().unique(),
name: text().notNull(), name: text().notNull(),
type: text().$type<RepositoryBackend>().notNull(), type: text().$type<RepositoryBackend>().notNull(),
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<RepositoryConfig>().notNull(),
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"), compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
status: text().$type<RepositoryStatus>().default("unknown"), status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "number" }), lastChecked: int("last_checked", { mode: "number" }),
@ -319,7 +319,7 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
name: text().notNull(), name: text().notNull(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true), enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
type: text().$type<NotificationType>().notNull(), type: text().$type<NotificationType>().notNull(),
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<NotificationConfig>().notNull(),
createdAt: int("created_at", { mode: "number" }) createdAt: int("created_at", { mode: "number" })
.notNull() .notNull()
.default(sql`(unixepoch() * 1000)`), .default(sql`(unixepoch() * 1000)`),

View file

@ -1,26 +1,30 @@
import { type } from "arktype"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
const statusResponseSchema = type({ const statusResponseSchema = z.object({
hasUsers: "boolean", hasUsers: z.boolean(),
}); });
export const adminUsersResponse = type({ export const adminUsersResponse = z.object({
users: type({ users: z
id: "string", .object({
name: "string | null", id: z.string(),
email: "string", name: z.string().nullable(),
role: "string", email: z.string(),
banned: "boolean", role: z.string(),
accounts: type({ banned: z.boolean(),
id: "string", accounts: z
providerId: "string", .object({
}).array(), id: z.string(),
}).array(), providerId: z.string(),
total: "number", })
.array(),
})
.array(),
total: z.number(),
}); });
export type AdminUsersDto = typeof adminUsersResponse.infer; export type AdminUsersDto = z.infer<typeof adminUsersResponse>;
export const getAdminUsersDto = describeRoute({ export const getAdminUsersDto = describeRoute({
description: "List admin users for settings management", description: "List admin users for settings management",
@ -54,21 +58,23 @@ export const getStatusDto = describeRoute({
}, },
}); });
export type GetStatusDto = typeof statusResponseSchema.infer; export type GetStatusDto = z.infer<typeof statusResponseSchema>;
export const userDeletionImpactDto = type({ export const userDeletionImpactDto = z.object({
organizations: type({ organizations: z
id: "string", .object({
name: "string", id: z.string(),
resources: { name: z.string(),
volumesCount: "number", resources: z.object({
repositoriesCount: "number", volumesCount: z.number(),
backupSchedulesCount: "number", repositoriesCount: z.number(),
}, backupSchedulesCount: z.number(),
}).array(), }),
})
.array(),
}); });
export type UserDeletionImpactDto = typeof userDeletionImpactDto.infer; export type UserDeletionImpactDto = z.infer<typeof userDeletionImpactDto>;
export const getUserDeletionImpactDto = describeRoute({ export const getUserDeletionImpactDto = describeRoute({
description: "Get impact of deleting a user", description: "Get impact of deleting a user",
@ -103,20 +109,22 @@ export const deleteUserAccountDto = describeRoute({
}, },
}); });
export const orgMembersResponse = type({ export const orgMembersResponse = z.object({
members: type({ members: z
id: "string", .object({
userId: "string", id: z.string(),
role: "string", userId: z.string(),
createdAt: "string", role: z.string(),
user: { createdAt: z.string(),
name: "string | null", user: z.object({
email: "string", name: z.string().nullable(),
}, email: z.string(),
}).array(), }),
})
.array(),
}); });
export type OrgMembersDto = typeof orgMembersResponse.infer; export type OrgMembersDto = z.infer<typeof orgMembersResponse>;
export const getOrgMembersDto = describeRoute({ export const getOrgMembersDto = describeRoute({
description: "Get members of the active organization", description: "Get members of the active organization",
@ -134,8 +142,8 @@ export const getOrgMembersDto = describeRoute({
}, },
}); });
export const updateMemberRoleBody = type({ export const updateMemberRoleBody = z.object({
role: "'member' | 'admin'", role: z.enum(["member", "admin"]),
}); });
export const updateMemberRoleDto = describeRoute({ export const updateMemberRoleDto = describeRoute({

View file

@ -41,5 +41,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
case "sftp": { case "sftp": {
return makeSftpBackend(volume.config, path); return makeSftpBackend(volume.config, path);
} }
default: {
throw new Error("Unsupported backend");
}
} }
}; };

View file

@ -1,67 +1,61 @@
import { type } from "arktype"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
import { volumeSchema } from "../volumes/volume.dto"; import { volumeSchema } from "../volumes/volume.dto";
import { repositorySchema } from "../repositories/repositories.dto"; import { repositorySchema } from "../repositories/repositories.dto";
import { backupProgressEventSchema } from "~/schemas/events-dto"; import { backupProgressEventSchema } from "~/schemas/events-dto";
const retentionPolicySchema = type({ const retentionPolicySchema = z.object({
keepLast: "number?", keepLast: z.number().optional(),
keepHourly: "number?", keepHourly: z.number().optional(),
keepDaily: "number?", keepDaily: z.number().optional(),
keepWeekly: "number?", keepWeekly: z.number().optional(),
keepMonthly: "number?", keepMonthly: z.number().optional(),
keepYearly: "number?", keepYearly: z.number().optional(),
keepWithinDuration: "string?", keepWithinDuration: z.string().optional(),
}); });
export type RetentionPolicy = typeof retentionPolicySchema.infer; export type RetentionPolicy = z.infer<typeof retentionPolicySchema>;
const backupScheduleSchema = type({ const backupScheduleSchema = z.object({
id: "number", id: z.number(),
shortId: "string", shortId: z.string(),
name: "string", name: z.string(),
volumeId: "number", volumeId: z.number(),
repositoryId: "string", repositoryId: z.string(),
enabled: "boolean", enabled: z.boolean(),
cronExpression: "string", cronExpression: z.string(),
retentionPolicy: retentionPolicySchema.or("null"), retentionPolicy: retentionPolicySchema.nullable(),
excludePatterns: "string[] | null", excludePatterns: z.array(z.string()).nullable(),
excludeIfPresent: "string[] | null", excludeIfPresent: z.array(z.string()).nullable(),
includePatterns: "string[] | null", includePatterns: z.array(z.string()).nullable(),
oneFileSystem: "boolean", oneFileSystem: z.boolean(),
customResticParams: "string[] | null", customResticParams: z.array(z.string()).nullable(),
lastBackupAt: "number | null", lastBackupAt: z.number().nullable(),
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null", lastBackupStatus: z.enum(["success", "error", "in_progress", "warning"]).nullable(),
lastBackupError: "string | null", lastBackupError: z.string().nullable(),
nextBackupAt: "number | null", nextBackupAt: z.number().nullable(),
createdAt: "number", createdAt: z.number(),
updatedAt: "number", updatedAt: z.number(),
}).and( volume: volumeSchema,
type({
volume: volumeSchema,
repository: repositorySchema,
}),
);
const scheduleMirrorSchema = type({
scheduleId: "string",
repositoryId: "string",
enabled: "boolean",
lastCopyAt: "number | null",
lastCopyStatus: "'success' | 'error' | 'in_progress' | null",
lastCopyError: "string | null",
createdAt: "number",
repository: repositorySchema, repository: repositorySchema,
}); });
export type ScheduleMirrorDto = typeof scheduleMirrorSchema.infer; const scheduleMirrorSchema = z.object({
scheduleId: z.string(),
repositoryId: z.string(),
enabled: z.boolean(),
lastCopyAt: z.number().nullable(),
lastCopyStatus: z.enum(["success", "error", "in_progress"]).nullable(),
lastCopyError: z.string().nullable(),
createdAt: z.number(),
repository: repositorySchema,
});
export type ScheduleMirrorDto = z.infer<typeof scheduleMirrorSchema>;
/**
* List all backup schedules
*/
export const listBackupSchedulesResponse = backupScheduleSchema.array(); export const listBackupSchedulesResponse = backupScheduleSchema.array();
export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer; export type ListBackupSchedulesResponseDto = z.infer<typeof listBackupSchedulesResponse>;
export const listBackupSchedulesDto = describeRoute({ export const listBackupSchedulesDto = describeRoute({
description: "List all backup schedules", description: "List all backup schedules",
@ -79,12 +73,9 @@ export const listBackupSchedulesDto = describeRoute({
}, },
}); });
/**
* Get a single backup schedule
*/
export const getBackupScheduleResponse = backupScheduleSchema; export const getBackupScheduleResponse = backupScheduleSchema;
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer; export type GetBackupScheduleDto = z.infer<typeof getBackupScheduleResponse>;
export const getBackupScheduleDto = describeRoute({ export const getBackupScheduleDto = describeRoute({
description: "Get a backup schedule by ID", description: "Get a backup schedule by ID",
@ -102,9 +93,9 @@ export const getBackupScheduleDto = describeRoute({
}, },
}); });
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.or("null"); export const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
export type GetBackupScheduleForVolumeResponseDto = typeof getBackupScheduleForVolumeResponse.infer; export type GetBackupScheduleForVolumeResponseDto = z.infer<typeof getBackupScheduleForVolumeResponse>;
export const getBackupScheduleForVolumeDto = describeRoute({ export const getBackupScheduleForVolumeDto = describeRoute({
description: "Get a backup schedule for a specific volume", description: "Get a backup schedule for a specific volume",
@ -122,29 +113,26 @@ export const getBackupScheduleForVolumeDto = describeRoute({
}, },
}); });
/** export const createBackupScheduleBody = z.object({
* Create a new backup schedule name: z.string().min(1).max(128),
*/ volumeId: z.union([z.string(), z.number()]),
export const createBackupScheduleBody = type({ repositoryId: z.string(),
name: "1 <= string <= 128", enabled: z.boolean(),
volumeId: "string | number", cronExpression: z.string(),
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(), retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?", excludePatterns: z.array(z.string()).optional(),
excludeIfPresent: "string[]?", excludeIfPresent: z.array(z.string()).optional(),
includePatterns: "string[]?", includePatterns: z.array(z.string()).optional(),
oneFileSystem: "boolean?", oneFileSystem: z.boolean().optional(),
tags: "string[]?", tags: z.array(z.string()).optional(),
customResticParams: "string[]?", customResticParams: z.array(z.string()).optional(),
}); });
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer; export type CreateBackupScheduleBody = z.infer<typeof createBackupScheduleBody>;
export const createBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository"); export const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer; export type CreateBackupScheduleDto = z.infer<typeof createBackupScheduleResponse>;
export const createBackupScheduleDto = describeRoute({ export const createBackupScheduleDto = describeRoute({
description: "Create a new backup schedule for a volume", description: "Create a new backup schedule for a volume",
@ -162,28 +150,25 @@ export const createBackupScheduleDto = describeRoute({
}, },
}); });
/** export const updateBackupScheduleBody = z.object({
* Update a backup schedule name: z.string().min(1).max(128).optional(),
*/ repositoryId: z.string(),
export const updateBackupScheduleBody = type({ enabled: z.boolean().optional(),
name: "(1 <= string <= 128)?", cronExpression: z.string(),
repositoryId: "string",
enabled: "boolean?",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(), retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?", excludePatterns: z.array(z.string()).optional(),
excludeIfPresent: "string[]?", excludeIfPresent: z.array(z.string()).optional(),
includePatterns: "string[]?", includePatterns: z.array(z.string()).optional(),
oneFileSystem: "boolean?", oneFileSystem: z.boolean().optional(),
tags: "string[]?", tags: z.array(z.string()).optional(),
customResticParams: "string[]?", customResticParams: z.array(z.string()).optional(),
}); });
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer; export type UpdateBackupScheduleBody = z.infer<typeof updateBackupScheduleBody>;
export const updateBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository"); export const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer; export type UpdateBackupScheduleDto = z.infer<typeof updateBackupScheduleResponse>;
export const updateBackupScheduleDto = describeRoute({ export const updateBackupScheduleDto = describeRoute({
description: "Update a backup schedule", description: "Update a backup schedule",
@ -201,14 +186,11 @@ export const updateBackupScheduleDto = describeRoute({
}, },
}); });
/** export const deleteBackupScheduleResponse = z.object({
* Delete a backup schedule success: z.boolean(),
*/
export const deleteBackupScheduleResponse = type({
success: "boolean",
}); });
export type DeleteBackupScheduleDto = typeof deleteBackupScheduleResponse.infer; export type DeleteBackupScheduleDto = z.infer<typeof deleteBackupScheduleResponse>;
export const deleteBackupScheduleDto = describeRoute({ export const deleteBackupScheduleDto = describeRoute({
description: "Delete a backup schedule", description: "Delete a backup schedule",
@ -226,14 +208,11 @@ export const deleteBackupScheduleDto = describeRoute({
}, },
}); });
/** export const runBackupNowResponse = z.object({
* Run a backup immediately success: z.boolean(),
*/
export const runBackupNowResponse = type({
success: "boolean",
}); });
export type RunBackupNowDto = typeof runBackupNowResponse.infer; export type RunBackupNowDto = z.infer<typeof runBackupNowResponse>;
export const runBackupNowDto = describeRoute({ export const runBackupNowDto = describeRoute({
description: "Trigger a backup immediately for a schedule", description: "Trigger a backup immediately for a schedule",
@ -251,14 +230,11 @@ export const runBackupNowDto = describeRoute({
}, },
}); });
/** export const stopBackupResponse = z.object({
* Stop a running backup success: z.boolean(),
*/
export const stopBackupResponse = type({
success: "boolean",
}); });
export type StopBackupDto = typeof stopBackupResponse.infer; export type StopBackupDto = z.infer<typeof stopBackupResponse>;
export const stopBackupDto = describeRoute({ export const stopBackupDto = describeRoute({
description: "Stop a backup that is currently in progress", description: "Stop a backup that is currently in progress",
@ -279,14 +255,11 @@ export const stopBackupDto = describeRoute({
}, },
}); });
/** export const runForgetResponse = z.object({
* Run retention policy (forget) manually success: z.boolean(),
*/
export const runForgetResponse = type({
success: "boolean",
}); });
export type RunForgetDto = typeof runForgetResponse.infer; export type RunForgetDto = z.infer<typeof runForgetResponse>;
export const runForgetDto = describeRoute({ export const runForgetDto = describeRoute({
description: "Manually apply retention policy to clean up old snapshots", description: "Manually apply retention policy to clean up old snapshots",
@ -305,7 +278,7 @@ export const runForgetDto = describeRoute({
}); });
export const getScheduleMirrorsResponse = scheduleMirrorSchema.array(); export const getScheduleMirrorsResponse = scheduleMirrorSchema.array();
export type GetScheduleMirrorsDto = typeof getScheduleMirrorsResponse.infer; export type GetScheduleMirrorsDto = z.infer<typeof getScheduleMirrorsResponse>;
export const getScheduleMirrorsDto = describeRoute({ export const getScheduleMirrorsDto = describeRoute({
description: "Get mirror repository assignments for a backup schedule", description: "Get mirror repository assignments for a backup schedule",
@ -323,17 +296,19 @@ export const getScheduleMirrorsDto = describeRoute({
}, },
}); });
export const updateScheduleMirrorsBody = type({ export const updateScheduleMirrorsBody = z.object({
mirrors: type({ mirrors: z
repositoryId: "string", .object({
enabled: "boolean", repositoryId: z.string(),
}).array(), enabled: z.boolean(),
})
.array(),
}); });
export type UpdateScheduleMirrorsBody = typeof updateScheduleMirrorsBody.infer; export type UpdateScheduleMirrorsBody = z.infer<typeof updateScheduleMirrorsBody>;
export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array(); export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array();
export type UpdateScheduleMirrorsDto = typeof updateScheduleMirrorsResponse.infer; export type UpdateScheduleMirrorsDto = z.infer<typeof updateScheduleMirrorsResponse>;
export const updateScheduleMirrorsDto = describeRoute({ export const updateScheduleMirrorsDto = describeRoute({
description: "Update mirror repository assignments for a backup schedule", description: "Update mirror repository assignments for a backup schedule",
@ -351,14 +326,14 @@ export const updateScheduleMirrorsDto = describeRoute({
}, },
}); });
const mirrorCompatibilitySchema = type({ const mirrorCompatibilitySchema = z.object({
repositoryId: "string", repositoryId: z.string(),
compatible: "boolean", compatible: z.boolean(),
reason: "string | null", reason: z.string().nullable(),
}); });
export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array(); export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array();
export type GetMirrorCompatibilityDto = typeof getMirrorCompatibilityResponse.infer; export type GetMirrorCompatibilityDto = z.infer<typeof getMirrorCompatibilityResponse>;
export const getMirrorCompatibilityDto = describeRoute({ export const getMirrorCompatibilityDto = describeRoute({
description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository", description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository",
@ -376,20 +351,17 @@ export const getMirrorCompatibilityDto = describeRoute({
}, },
}); });
/** export const reorderBackupSchedulesBody = z.object({
* Reorder backup schedules scheduleShortIds: z.array(z.string()),
*/
export const reorderBackupSchedulesBody = type({
scheduleShortIds: "string[]",
}); });
export type ReorderBackupSchedulesBody = typeof reorderBackupSchedulesBody.infer; export type ReorderBackupSchedulesBody = z.infer<typeof reorderBackupSchedulesBody>;
export const reorderBackupSchedulesResponse = type({ export const reorderBackupSchedulesResponse = z.object({
success: "boolean", success: z.boolean(),
}); });
export type ReorderBackupSchedulesDto = typeof reorderBackupSchedulesResponse.infer; export type ReorderBackupSchedulesDto = z.infer<typeof reorderBackupSchedulesResponse>;
export const reorderBackupSchedulesDto = describeRoute({ export const reorderBackupSchedulesDto = describeRoute({
description: "Reorder backup schedules by providing an array of schedule short IDs in the desired order", description: "Reorder backup schedules by providing an array of schedule short IDs in the desired order",
@ -407,11 +379,8 @@ export const reorderBackupSchedulesDto = describeRoute({
}, },
}); });
/** const getBackupProgressResponse = backupProgressEventSchema.nullable();
* Get current backup progress for a running backup export type GetBackupProgressDto = z.infer<typeof getBackupProgressResponse>;
*/
const getBackupProgressResponse = backupProgressEventSchema.or("null");
export type GetBackupProgressDto = typeof getBackupProgressResponse.infer;
export const getBackupProgressDto = describeRoute({ export const getBackupProgressDto = describeRoute({
description: description:

View file

@ -5,7 +5,6 @@ import { logger } from "../../../utils/logger";
import { toMessage } from "~/server/utils/errors"; import { toMessage } from "~/server/utils/errors";
import { REPOSITORY_BASE } from "~/server/core/constants"; import { REPOSITORY_BASE } from "~/server/core/constants";
import { repositoryConfigSchema } from "~/schemas/restic"; import { repositoryConfigSchema } from "~/schemas/restic";
import { type } from "arktype";
type MigrationError = { name: string; error: string }; type MigrationError = { name: string; error: string };
@ -68,14 +67,15 @@ const execute = async () => {
config.path = buildPath(currentPath, localRepositoryName); config.path = buildPath(currentPath, localRepositoryName);
const newConfig = repositoryConfigSchema(config); const newConfigResult = repositoryConfigSchema.safeParse(config);
if (newConfig instanceof type.errors) { if (!newConfigResult.success) {
errors.push({ errors.push({
name: `repository:${repository.id}`, name: `repository:${repository.id}`,
error: `Validation failed for updated repository config: ${newConfig.summary}`, error: `Validation failed for updated repository config: ${newConfigResult.error.message}`,
}); });
continue; continue;
} }
const newConfig = newConfigResult.data;
await db await db
.update(repositoriesTable) .update(repositoriesTable)

View file

@ -1,27 +1,21 @@
import { type } from "arktype"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
import { NOTIFICATION_TYPES, notificationConfigSchema } from "~/schemas/notifications"; import { NOTIFICATION_TYPES, notificationConfigSchema } from "~/schemas/notifications";
/** export const notificationDestinationSchema = z.object({
* Notification Destination Schema id: z.number(),
*/ name: z.string(),
export const notificationDestinationSchema = type({ enabled: z.boolean(),
id: "number", type: z.enum(NOTIFICATION_TYPES),
name: "string",
enabled: "boolean",
type: type.valueOf(NOTIFICATION_TYPES),
config: notificationConfigSchema, config: notificationConfigSchema,
createdAt: "number", createdAt: z.number(),
updatedAt: "number", updatedAt: z.number(),
}); });
export type NotificationDestinationDto = typeof notificationDestinationSchema.infer; export type NotificationDestinationDto = z.infer<typeof notificationDestinationSchema>;
/**
* List all notification destinations
*/
export const listDestinationsResponse = notificationDestinationSchema.array(); export const listDestinationsResponse = notificationDestinationSchema.array();
export type ListDestinationsDto = typeof listDestinationsResponse.infer; export type ListDestinationsDto = z.infer<typeof listDestinationsResponse>;
export const listDestinationsDto = describeRoute({ export const listDestinationsDto = describeRoute({
description: "List all notification destinations", description: "List all notification destinations",
@ -39,16 +33,13 @@ export const listDestinationsDto = describeRoute({
}, },
}); });
/** export const createDestinationBody = z.object({
* Create a new notification destination name: z.string(),
*/
export const createDestinationBody = type({
name: "string",
config: notificationConfigSchema, config: notificationConfigSchema,
}); });
export const createDestinationResponse = notificationDestinationSchema; export const createDestinationResponse = notificationDestinationSchema;
export type CreateDestinationDto = typeof createDestinationResponse.infer; export type CreateDestinationDto = z.infer<typeof createDestinationResponse>;
export const createDestinationDto = describeRoute({ export const createDestinationDto = describeRoute({
description: "Create a new notification destination", description: "Create a new notification destination",
@ -66,11 +57,8 @@ export const createDestinationDto = describeRoute({
}, },
}); });
/**
* Get a single notification destination
*/
export const getDestinationResponse = notificationDestinationSchema; export const getDestinationResponse = notificationDestinationSchema;
export type GetDestinationDto = typeof getDestinationResponse.infer; export type GetDestinationDto = z.infer<typeof getDestinationResponse>;
export const getDestinationDto = describeRoute({ export const getDestinationDto = describeRoute({
description: "Get a notification destination by ID", description: "Get a notification destination by ID",
@ -91,17 +79,14 @@ export const getDestinationDto = describeRoute({
}, },
}); });
/** export const updateDestinationBody = z.object({
* Update a notification destination name: z.string().optional(),
*/ enabled: z.boolean().optional(),
export const updateDestinationBody = type({ config: notificationConfigSchema.optional(),
"name?": "string",
"enabled?": "boolean",
"config?": notificationConfigSchema,
}); });
export const updateDestinationResponse = notificationDestinationSchema; export const updateDestinationResponse = notificationDestinationSchema;
export type UpdateDestinationDto = typeof updateDestinationResponse.infer; export type UpdateDestinationDto = z.infer<typeof updateDestinationResponse>;
export const updateDestinationDto = describeRoute({ export const updateDestinationDto = describeRoute({
description: "Update a notification destination", description: "Update a notification destination",
@ -122,13 +107,10 @@ export const updateDestinationDto = describeRoute({
}, },
}); });
/** export const deleteDestinationResponse = z.object({
* Delete a notification destination message: z.string(),
*/
export const deleteDestinationResponse = type({
message: "string",
}); });
export type DeleteDestinationDto = typeof deleteDestinationResponse.infer; export type DeleteDestinationDto = z.infer<typeof deleteDestinationResponse>;
export const deleteDestinationDto = describeRoute({ export const deleteDestinationDto = describeRoute({
description: "Delete a notification destination", description: "Delete a notification destination",
@ -149,13 +131,10 @@ export const deleteDestinationDto = describeRoute({
}, },
}); });
/** export const testDestinationResponse = z.object({
* Test a notification destination success: z.boolean(),
*/
export const testDestinationResponse = type({
success: "boolean",
}); });
export type TestDestinationDto = typeof testDestinationResponse.infer; export type TestDestinationDto = z.infer<typeof testDestinationResponse>;
export const testDestinationDto = describeRoute({ export const testDestinationDto = describeRoute({
description: "Test a notification destination by sending a test message", description: "Test a notification destination by sending a test message",
@ -182,27 +161,21 @@ export const testDestinationDto = describeRoute({
}, },
}); });
/** export const scheduleNotificationAssignmentSchema = z.object({
* Backup Schedule Notification Assignment Schema scheduleId: z.number(),
*/ destinationId: z.number(),
export const scheduleNotificationAssignmentSchema = type({ notifyOnStart: z.boolean(),
scheduleId: "number", notifyOnSuccess: z.boolean(),
destinationId: "number", notifyOnWarning: z.boolean(),
notifyOnStart: "boolean", notifyOnFailure: z.boolean(),
notifyOnSuccess: "boolean", createdAt: z.number(),
notifyOnWarning: "boolean",
notifyOnFailure: "boolean",
createdAt: "number",
destination: notificationDestinationSchema, destination: notificationDestinationSchema,
}); });
export type ScheduleNotificationAssignmentDto = typeof scheduleNotificationAssignmentSchema.infer; export type ScheduleNotificationAssignmentDto = z.infer<typeof scheduleNotificationAssignmentSchema>;
/**
* Get notifications for a backup schedule
*/
export const getScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array(); export const getScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
export type GetScheduleNotificationsDto = typeof getScheduleNotificationsResponse.infer; export type GetScheduleNotificationsDto = z.infer<typeof getScheduleNotificationsResponse>;
export const getScheduleNotificationsDto = describeRoute({ export const getScheduleNotificationsDto = describeRoute({
description: "Get notification assignments for a backup schedule", description: "Get notification assignments for a backup schedule",
@ -220,21 +193,20 @@ export const getScheduleNotificationsDto = describeRoute({
}, },
}); });
/** export const updateScheduleNotificationsBody = z.object({
* Update notifications for a backup schedule assignments: z
*/ .object({
export const updateScheduleNotificationsBody = type({ destinationId: z.number(),
assignments: type({ notifyOnStart: z.boolean(),
destinationId: "number", notifyOnSuccess: z.boolean(),
notifyOnStart: "boolean", notifyOnWarning: z.boolean(),
notifyOnSuccess: "boolean", notifyOnFailure: z.boolean(),
notifyOnWarning: "boolean", })
notifyOnFailure: "boolean", .array(),
}).array(),
}); });
export const updateScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array(); export const updateScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
export type UpdateScheduleNotificationsDto = typeof updateScheduleNotificationsResponse.infer; export type UpdateScheduleNotificationsDto = z.infer<typeof updateScheduleNotificationsResponse>;
export const updateScheduleNotificationsDto = describeRoute({ export const updateScheduleNotificationsDto = describeRoute({
description: "Update notification assignments for a backup schedule", description: "Update notification assignments for a backup schedule",

View file

@ -14,7 +14,6 @@ import { buildShoutrrrUrl } from "./builders";
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications"; import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
import type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto"; import type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { type } from "arktype";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
import { formatBytes } from "~/utils/format-bytes"; import { formatBytes } from "~/utils/format-bytes";
@ -192,10 +191,11 @@ const updateDestination = async (
updateData.enabled = updates.enabled; updateData.enabled = updates.enabled;
} }
const newConfig = notificationConfigSchema(updates.config || existing.config); const newConfigResult = notificationConfigSchema.safeParse(updates.config || existing.config);
if (newConfig instanceof type.errors) { if (!newConfigResult.success) {
throw new BadRequestError("Invalid notification configuration"); throw new BadRequestError("Invalid notification configuration");
} }
const newConfig = newConfigResult.data;
const encryptedConfig = await encryptSensitiveFields(newConfig); const encryptedConfig = await encryptSensitiveFields(newConfig);
updateData.config = encryptedConfig; updateData.config = encryptedConfig;

View file

@ -1,6 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/schemas/restic"; import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/schemas/restic";
import { type } from "arktype"; import { z } from "zod";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
import { restic } from "~/server/utils/restic"; import { restic } from "~/server/utils/restic";
import { toMessage } from "~/server/utils/errors"; import { toMessage } from "~/server/utils/errors";
@ -61,21 +61,24 @@ const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal)
}; };
const parseCheckOutput = (checkOutput: string | null) => { const parseCheckOutput = (checkOutput: string | null) => {
const schema = type({ suggest_repair_index: "boolean", suggest_prune: "boolean" }); const schema = z.object({
suggest_repair_index: z.boolean(),
suggest_prune: z.boolean(),
});
const parsedJson = safeJsonParse(checkOutput); const parsedJson = safeJsonParse(checkOutput);
if (parsedJson === null) { if (parsedJson === null) {
return null; return null;
} }
const parsed = schema(parsedJson); const parsed = schema.safeParse(parsedJson);
if (parsed instanceof type.errors) { if (!parsed.success) {
logger.error(`Invalid check output format: ${parsed.summary}`); logger.error(`Invalid check output format: ${parsed.error.message}`);
return null; return null;
} }
return parsed; return parsed.data;
}; };
const checkAbortSignal = (signal: AbortSignal | undefined): void => { const checkAbortSignal = (signal: AbortSignal | undefined): void => {

View file

@ -179,8 +179,8 @@ export const repositoriesController = new Hono()
const decodedPath = path ? decodeURIComponent(path) : undefined; const decodedPath = path ? decodeURIComponent(path) : undefined;
const offset = Math.max(0, Number.parseInt(query.offset ?? "0", 10) || 0); const offset = Math.max(0, query.offset ?? 0);
const limit = Math.min(1000, Math.max(1, Number.parseInt(query.limit ?? "500", 10) || 500)); const limit = Math.min(1000, Math.max(1, query.limit ?? 500));
const result = await repositoriesService.listSnapshotFiles(shortId, snapshotId, decodedPath, { offset, limit }); const result = await repositoriesService.listSnapshotFiles(shortId, snapshotId, decodedPath, { offset, limit });

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
import { import {
COMPRESSION_MODES, COMPRESSION_MODES,
@ -10,28 +10,25 @@ import {
} from "~/schemas/restic"; } from "~/schemas/restic";
import { resticSnapshotSummarySchema, resticStatsSchema } from "~/schemas/restic-dto"; import { resticSnapshotSummarySchema, resticStatsSchema } from "~/schemas/restic-dto";
export const repositorySchema = type({ export const repositorySchema = z.object({
id: "string", id: z.string(),
shortId: "string", shortId: z.string(),
name: "string", name: z.string(),
type: type.valueOf(REPOSITORY_BACKENDS), type: z.enum(REPOSITORY_BACKENDS),
config: repositoryConfigSchema, config: repositoryConfigSchema,
compressionMode: type.valueOf(COMPRESSION_MODES).or("null"), compressionMode: z.enum(COMPRESSION_MODES).nullable(),
status: type.valueOf(REPOSITORY_STATUS).or("null"), status: z.enum(REPOSITORY_STATUS).nullable(),
lastChecked: "number | null", lastChecked: z.number().nullable(),
lastError: "string | null", lastError: z.string().nullable(),
doctorResult: doctorResultSchema.or("null"), doctorResult: doctorResultSchema.nullable(),
createdAt: "number", createdAt: z.number(),
updatedAt: "number", updatedAt: z.number(),
}); });
export type RepositoryDto = typeof repositorySchema.infer; export type RepositoryDto = z.infer<typeof repositorySchema>;
/**
* List all repositories
*/
export const listRepositoriesResponse = repositorySchema.array(); export const listRepositoriesResponse = repositorySchema.array();
export type ListRepositoriesDto = typeof listRepositoriesResponse.infer; export type ListRepositoriesDto = z.infer<typeof listRepositoriesResponse>;
export const listRepositoriesDto = describeRoute({ export const listRepositoriesDto = describeRoute({
description: "List all repositories", description: "List all repositories",
@ -49,27 +46,24 @@ export const listRepositoriesDto = describeRoute({
}, },
}); });
/** export const createRepositoryBody = z.object({
* Create a new repository name: z.string(),
*/ compressionMode: z.enum(COMPRESSION_MODES).optional(),
export const createRepositoryBody = type({
name: "string",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
config: repositoryConfigSchema, config: repositoryConfigSchema,
}); });
export type CreateRepositoryBody = typeof createRepositoryBody.infer; export type CreateRepositoryBody = z.infer<typeof createRepositoryBody>;
export const createRepositoryResponse = type({ export const createRepositoryResponse = z.object({
message: "string", message: z.string(),
repository: type({ repository: z.object({
id: "string", id: z.string(),
shortId: "string", shortId: z.string(),
name: "string", name: z.string(),
}), }),
}); });
export type CreateRepositoryDto = typeof createRepositoryResponse.infer; export type CreateRepositoryDto = z.infer<typeof createRepositoryResponse>;
export const createRepositoryDto = describeRoute({ export const createRepositoryDto = describeRoute({
description: "Create a new restic repository", description: "Create a new restic repository",
@ -87,11 +81,8 @@ export const createRepositoryDto = describeRoute({
}, },
}); });
/**
* Get a single repository
*/
export const getRepositoryResponse = repositorySchema; export const getRepositoryResponse = repositorySchema;
export type GetRepositoryDto = typeof getRepositoryResponse.infer; export type GetRepositoryDto = z.infer<typeof getRepositoryResponse>;
export const getRepositoryDto = describeRoute({ export const getRepositoryDto = describeRoute({
description: "Get a single repository by ID", description: "Get a single repository by ID",
@ -109,12 +100,9 @@ export const getRepositoryDto = describeRoute({
}, },
}); });
/**
* Get repository stats
*/
export const repositoryStatsSchema = resticStatsSchema; export const repositoryStatsSchema = resticStatsSchema;
export const getRepositoryStatsResponse = repositoryStatsSchema; export const getRepositoryStatsResponse = repositoryStatsSchema;
export type GetRepositoryStatsDto = typeof getRepositoryStatsResponse.infer; export type GetRepositoryStatsDto = z.infer<typeof getRepositoryStatsResponse>;
export const getRepositoryStatsDto = describeRoute({ export const getRepositoryStatsDto = describeRoute({
description: "Get repository storage and compression statistics", description: "Get repository storage and compression statistics",
@ -133,7 +121,7 @@ export const getRepositoryStatsDto = describeRoute({
}); });
export const refreshRepositoryStatsResponse = repositoryStatsSchema; export const refreshRepositoryStatsResponse = repositoryStatsSchema;
export type RefreshRepositoryStatsDto = typeof refreshRepositoryStatsResponse.infer; export type RefreshRepositoryStatsDto = z.infer<typeof refreshRepositoryStatsResponse>;
export const refreshRepositoryStatsDto = describeRoute({ export const refreshRepositoryStatsDto = describeRoute({
description: "Refresh repository storage and compression statistics", description: "Refresh repository storage and compression statistics",
@ -151,14 +139,11 @@ export const refreshRepositoryStatsDto = describeRoute({
}, },
}); });
/** export const deleteRepositoryResponse = z.object({
* Delete a repository message: z.string(),
*/
export const deleteRepositoryResponse = type({
message: "string",
}); });
export type DeleteRepositoryDto = typeof deleteRepositoryResponse.infer; export type DeleteRepositoryDto = z.infer<typeof deleteRepositoryResponse>;
export const deleteRepositoryDto = describeRoute({ export const deleteRepositoryDto = describeRoute({
description: "Delete a repository", description: "Delete a repository",
@ -176,19 +161,16 @@ export const deleteRepositoryDto = describeRoute({
}, },
}); });
/** export const updateRepositoryBody = z.object({
* Update a repository name: z.string().optional(),
*/ compressionMode: z.enum(COMPRESSION_MODES).optional(),
export const updateRepositoryBody = type({
name: "string?",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
config: repositoryConfigSchema.optional(), config: repositoryConfigSchema.optional(),
}); });
export type UpdateRepositoryBody = typeof updateRepositoryBody.infer; export type UpdateRepositoryBody = z.infer<typeof updateRepositoryBody>;
export const updateRepositoryResponse = repositorySchema; export const updateRepositoryResponse = repositorySchema;
export type UpdateRepositoryDto = typeof updateRepositoryResponse.infer; export type UpdateRepositoryDto = z.infer<typeof updateRepositoryResponse>;
export const updateRepositoryDto = describeRoute({ export const updateRepositoryDto = describeRoute({
description: "Update a repository's name or settings", description: "Update a repository's name or settings",
@ -215,27 +197,24 @@ export const updateRepositoryDto = describeRoute({
}, },
}); });
/** export const snapshotSchema = z.object({
* List snapshots in a repository short_id: z.string(),
*/ time: z.number(),
export const snapshotSchema = type({ paths: z.array(z.string()),
short_id: "string", size: z.number(),
time: "number", duration: z.number(),
paths: "string[]", tags: z.array(z.string()),
size: "number", retentionCategories: z.array(z.string()),
duration: "number", hostname: z.string().optional(),
tags: "string[]",
retentionCategories: "string[]",
hostname: "string?",
summary: resticSnapshotSummarySchema.optional(), summary: resticSnapshotSummarySchema.optional(),
}); });
const listSnapshotsResponse = snapshotSchema.array(); const listSnapshotsResponse = snapshotSchema.array();
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer; export type ListSnapshotsDto = z.infer<typeof listSnapshotsResponse>;
export const listSnapshotsFilters = type({ export const listSnapshotsFilters = z.object({
backupId: "string?", backupId: z.string().optional(),
}); });
export const listSnapshotsDto = describeRoute({ export const listSnapshotsDto = describeRoute({
@ -254,12 +233,9 @@ export const listSnapshotsDto = describeRoute({
}, },
}); });
/**
* Get snapshot details
*/
export const getSnapshotDetailsResponse = snapshotSchema; export const getSnapshotDetailsResponse = snapshotSchema;
export type GetSnapshotDetailsDto = typeof getSnapshotDetailsResponse.infer; export type GetSnapshotDetailsDto = z.infer<typeof getSnapshotDetailsResponse>;
export const getSnapshotDetailsDto = describeRoute({ export const getSnapshotDetailsDto = describeRoute({
description: "Get details of a specific snapshot", description: "Get details of a specific snapshot",
@ -277,43 +253,40 @@ export const getSnapshotDetailsDto = describeRoute({
}, },
}); });
/** export const snapshotFileNodeSchema = z.object({
* List files in a snapshot name: z.string(),
*/ type: z.string(),
export const snapshotFileNodeSchema = type({ path: z.string(),
name: "string", uid: z.number().optional(),
type: "string", gid: z.number().optional(),
path: "string", size: z.number().optional(),
uid: "number?", mode: z.number().optional(),
gid: "number?", mtime: z.string().optional(),
size: "number?", atime: z.string().optional(),
mode: "number?", ctime: z.string().optional(),
mtime: "string?",
atime: "string?",
ctime: "string?",
}); });
export const listSnapshotFilesResponse = type({ export const listSnapshotFilesResponse = z.object({
snapshot: type({ snapshot: z.object({
id: "string", id: z.string(),
short_id: "string", short_id: z.string(),
time: "string", time: z.string(),
hostname: "string", hostname: z.string().optional(),
paths: "string[]", paths: z.array(z.string()),
}), }),
files: snapshotFileNodeSchema.array(), files: snapshotFileNodeSchema.array(),
offset: "number", offset: z.number(),
limit: "number", limit: z.number(),
total: "number", total: z.number(),
hasMore: "boolean", hasMore: z.boolean(),
}); });
export type ListSnapshotFilesDto = typeof listSnapshotFilesResponse.infer; export type ListSnapshotFilesDto = z.infer<typeof listSnapshotFilesResponse>;
export const listSnapshotFilesQuery = type({ export const listSnapshotFilesQuery = z.object({
path: "string?", path: z.string().optional(),
offset: "string.integer?", offset: z.coerce.number().int().optional(),
limit: "string.integer?", limit: z.coerce.number().int().optional(),
}); });
export const listSnapshotFilesDto = describeRoute({ export const listSnapshotFilesDto = describeRoute({
@ -337,14 +310,11 @@ const DUMP_PATH_KINDS = {
dir: "dir", dir: "dir",
} as const; } as const;
export const dumpPathKindSchema = type.valueOf(DUMP_PATH_KINDS); export const dumpPathKindSchema = z.enum(DUMP_PATH_KINDS);
export type DumpPathKind = typeof dumpPathKindSchema.infer; export type DumpPathKind = z.infer<typeof dumpPathKindSchema>;
/** export const dumpSnapshotQuery = z.object({
* Download snapshot paths as tar archives (folders) or raw file streams (single files) path: z.string().optional(),
*/
export const dumpSnapshotQuery = type({
path: "string?",
kind: dumpPathKindSchema.optional(), kind: dumpPathKindSchema.optional(),
}); });
@ -367,32 +337,29 @@ export const dumpSnapshotDto = describeRoute({
}, },
}); });
/** export const overwriteModeSchema = z.enum(OVERWRITE_MODES);
* Restore a snapshot
*/
export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
export const restoreSnapshotBody = type({ export const restoreSnapshotBody = z.object({
snapshotId: "string", snapshotId: z.string(),
include: "string[]?", include: z.array(z.string()).optional(),
selectedItemKind: dumpPathKindSchema.optional(), selectedItemKind: dumpPathKindSchema.optional(),
exclude: "string[]?", exclude: z.array(z.string()).optional(),
excludeXattr: "string[]?", excludeXattr: z.array(z.string()).optional(),
delete: "boolean?", delete: z.boolean().optional(),
targetPath: "string?", targetPath: z.string().optional(),
overwrite: overwriteModeSchema.optional(), overwrite: overwriteModeSchema.optional(),
}); });
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer; export type RestoreSnapshotBody = z.infer<typeof restoreSnapshotBody>;
export const restoreSnapshotResponse = type({ export const restoreSnapshotResponse = z.object({
success: "boolean", success: z.boolean(),
message: "string", message: z.string(),
filesRestored: "number", filesRestored: z.number(),
filesSkipped: "number", filesSkipped: z.number(),
}); });
export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer; export type RestoreSnapshotDto = z.infer<typeof restoreSnapshotResponse>;
export const restoreSnapshotDto = describeRoute({ export const restoreSnapshotDto = describeRoute({
description: "Restore a snapshot to a target path on the filesystem", description: "Restore a snapshot to a target path on the filesystem",
@ -410,15 +377,12 @@ export const restoreSnapshotDto = describeRoute({
}, },
}); });
/** export const startDoctorResponse = z.object({
* Start doctor operation message: z.string(),
*/ repositoryId: z.string(),
export const startDoctorResponse = type({
message: "string",
repositoryId: "string",
}); });
export type StartDoctorDto = typeof startDoctorResponse.infer; export type StartDoctorDto = z.infer<typeof startDoctorResponse>;
export const startDoctorDto = describeRoute({ export const startDoctorDto = describeRoute({
description: description:
@ -440,14 +404,11 @@ export const startDoctorDto = describeRoute({
}, },
}); });
/** export const cancelDoctorResponse = z.object({
* Cancel running doctor operation message: z.string(),
*/
export const cancelDoctorResponse = type({
message: "string",
}); });
export type CancelDoctorDto = typeof cancelDoctorResponse.infer; export type CancelDoctorDto = z.infer<typeof cancelDoctorResponse>;
export const cancelDoctorDto = describeRoute({ export const cancelDoctorDto = describeRoute({
description: "Cancel a running doctor operation on a repository", description: "Cancel a running doctor operation on a repository",
@ -468,12 +429,9 @@ export const cancelDoctorDto = describeRoute({
}, },
}); });
/** const rcloneRemoteSchema = z.object({
* List rclone available remotes name: z.string(),
*/ type: z.string(),
const rcloneRemoteSchema = type({
name: "string",
type: "string",
}); });
const listRcloneRemotesResponse = rcloneRemoteSchema.array(); const listRcloneRemotesResponse = rcloneRemoteSchema.array();
@ -494,14 +452,11 @@ export const listRcloneRemotesDto = describeRoute({
}, },
}); });
/** export const deleteSnapshotResponse = z.object({
* Delete a snapshot message: z.string(),
*/
export const deleteSnapshotResponse = type({
message: "string",
}); });
export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer; export type DeleteSnapshotDto = z.infer<typeof deleteSnapshotResponse>;
export const deleteSnapshotDto = describeRoute({ export const deleteSnapshotDto = describeRoute({
description: "Delete a specific snapshot from a repository", description: "Delete a specific snapshot from a repository",
@ -519,18 +474,15 @@ export const deleteSnapshotDto = describeRoute({
}, },
}); });
/** export const deleteSnapshotsBody = z.object({
* Delete multiple snapshots snapshotIds: z.array(z.string()).min(1),
*/
export const deleteSnapshotsBody = type({
snapshotIds: "string[]>=1",
}); });
export const deleteSnapshotsResponse = type({ export const deleteSnapshotsResponse = z.object({
message: "string", message: z.string(),
}); });
export type DeleteSnapshotsResponseDto = typeof deleteSnapshotsResponse.infer; export type DeleteSnapshotsResponseDto = z.infer<typeof deleteSnapshotsResponse>;
export const deleteSnapshotsDto = describeRoute({ export const deleteSnapshotsDto = describeRoute({
description: "Delete multiple snapshots from a repository", description: "Delete multiple snapshots from a repository",
@ -548,21 +500,18 @@ export const deleteSnapshotsDto = describeRoute({
}, },
}); });
/** export const tagSnapshotsBody = z.object({
* Tag multiple snapshots snapshotIds: z.array(z.string()).min(1),
*/ add: z.array(z.string()).optional(),
export const tagSnapshotsBody = type({ remove: z.array(z.string()).optional(),
snapshotIds: "string[]>=1", set: z.array(z.string()).optional(),
add: "string[]?",
remove: "string[]?",
set: "string[]?",
}); });
export const tagSnapshotsResponse = type({ export const tagSnapshotsResponse = z.object({
message: "string", message: z.string(),
}); });
export type TagSnapshotsResponseDto = typeof tagSnapshotsResponse.infer; export type TagSnapshotsResponseDto = z.infer<typeof tagSnapshotsResponse>;
export const tagSnapshotsDto = describeRoute({ export const tagSnapshotsDto = describeRoute({
description: "Tag multiple snapshots in a repository", description: "Tag multiple snapshots in a repository",
@ -580,15 +529,12 @@ export const tagSnapshotsDto = describeRoute({
}, },
}); });
/** export const refreshSnapshotsResponse = z.object({
* Refresh snapshots cache message: z.string(),
*/ count: z.number(),
export const refreshSnapshotsResponse = type({
message: "string",
count: "number",
}); });
export type RefreshSnapshotsDto = typeof refreshSnapshotsResponse.infer; export type RefreshSnapshotsDto = z.infer<typeof refreshSnapshotsResponse>;
export const refreshSnapshotsDto = describeRoute({ export const refreshSnapshotsDto = describeRoute({
description: "Clear snapshot cache and force refresh from repository", description: "Clear snapshot cache and force refresh from repository",
@ -606,12 +552,12 @@ export const refreshSnapshotsDto = describeRoute({
}, },
}); });
export const devPanelExecBody = type({ export const devPanelExecBody = z.object({
command: "string", command: z.string(),
args: "string[]?", args: z.array(z.string()).optional(),
}); });
export type DevPanelExecBody = typeof devPanelExecBody.infer; export type DevPanelExecBody = z.infer<typeof devPanelExecBody>;
export const devPanelExecDto = describeRoute({ export const devPanelExecDto = describeRoute({
description: "Execute a restic command against a repository (dev panel only)", description: "Execute a restic command against a repository (dev panel only)",
@ -632,15 +578,12 @@ export const devPanelExecDto = describeRoute({
}, },
}); });
/** export const unlockRepositoryResponse = z.object({
* Unlock repository success: z.boolean(),
*/ message: z.string(),
export const unlockRepositoryResponse = type({
success: "boolean",
message: "string",
}); });
export type UnlockRepositoryDto = typeof unlockRepositoryResponse.infer; export type UnlockRepositoryDto = z.infer<typeof unlockRepositoryResponse>;
export const unlockRepositoryDto = describeRoute({ export const unlockRepositoryDto = describeRoute({
description: "Unlock a repository by removing all stale locks", description: "Unlock a repository by removing all stale locks",

View file

@ -1,6 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import nodePath from "node:path"; import nodePath from "node:path";
import { type } from "arktype";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import { import {
@ -732,10 +731,11 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const existingConfig = repositoryConfigSchema(existing.config); const existingConfigResult = repositoryConfigSchema.safeParse(existing.config);
if (existingConfig instanceof type.errors) { if (!existingConfigResult.success) {
throw new InternalServerError("Invalid repository configuration"); throw new InternalServerError("Invalid repository configuration");
} }
const existingConfig = existingConfigResult.data;
let newName = existing.name; let newName = existing.name;
if (updates.name) { if (updates.name) {
@ -747,10 +747,11 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
let parsedConfig = existingConfig; let parsedConfig = existingConfig;
if (updates.config) { if (updates.config) {
const nextConfig = repositoryConfigSchema(updates.config); const nextConfigResult = repositoryConfigSchema.safeParse(updates.config);
if (nextConfig instanceof type.errors) { if (!nextConfigResult.success) {
throw new BadRequestError("Invalid repository configuration"); throw new BadRequestError("Invalid repository configuration");
} }
const nextConfig = nextConfigResult.data;
if (nextConfig.backend !== existing.type) { if (nextConfig.backend !== existing.type) {
throw new BadRequestError("Repository backend cannot be changed"); throw new BadRequestError("Repository backend cannot be changed");
@ -759,7 +760,7 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
parsedConfig = nextConfig; parsedConfig = nextConfig;
} }
const decryptedExisting = existingConfig ? await decryptConfig(existingConfig) : null; const decryptedExisting = await decryptConfig(existingConfig);
const configChanged = updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig); const configChanged = updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig);
const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig; const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig;

View file

@ -1,16 +1,16 @@
import { type } from "arktype"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
export const publicSsoProvidersDto = type({ export const publicSsoProvidersDto = z.object({
providers: type({ providers: z
providerId: "string", .object({
organizationSlug: "string", providerId: z.string(),
}) organizationSlug: z.string(),
.onUndeclaredKey("delete") })
.array(), .array(),
}); });
export type PublicSsoProvidersDto = typeof publicSsoProvidersDto.infer; export type PublicSsoProvidersDto = z.infer<typeof publicSsoProvidersDto>;
export const getPublicSsoProvidersDto = describeRoute({ export const getPublicSsoProvidersDto = describeRoute({
description: "Get public SSO providers for the instance", description: "Get public SSO providers for the instance",
@ -28,25 +28,29 @@ export const getPublicSsoProvidersDto = describeRoute({
}, },
}); });
export const ssoSettingsResponse = type({ export const ssoSettingsResponse = z.object({
providers: type({ providers: z
providerId: "string", .object({
type: "string", providerId: z.string(),
issuer: "string", type: z.string(),
domain: "string", issuer: z.string(),
autoLinkMatchingEmails: "boolean", domain: z.string(),
organizationId: "string | null", autoLinkMatchingEmails: z.boolean(),
}).array(), organizationId: z.string().nullable(),
invitations: type({ })
id: "string", .array(),
email: "string", invitations: z
role: "string", .object({
status: "string", id: z.string(),
expiresAt: "string", email: z.string(),
}).array(), role: z.string(),
status: z.string(),
expiresAt: z.string(),
})
.array(),
}); });
export type SsoSettingsDto = typeof ssoSettingsResponse.infer; export type SsoSettingsDto = z.infer<typeof ssoSettingsResponse>;
export const getSsoSettingsDto = describeRoute({ export const getSsoSettingsDto = describeRoute({
description: "Get SSO providers and invitations for the active organization", description: "Get SSO providers and invitations for the active organization",
@ -95,8 +99,8 @@ export const deleteSsoInvitationDto = describeRoute({
}, },
}); });
export const updateSsoProviderAutoLinkingBody = type({ export const updateSsoProviderAutoLinkingBody = z.object({
enabled: "boolean", enabled: z.boolean(),
}); });
export const updateSsoProviderAutoLinkingDto = describeRoute({ export const updateSsoProviderAutoLinkingDto = describeRoute({

View file

@ -1,32 +1,32 @@
import { type } from "arktype"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
export const capabilitiesSchema = type({ export const capabilitiesSchema = z.object({
rclone: "boolean", rclone: z.boolean(),
sysAdmin: "boolean", sysAdmin: z.boolean(),
}); });
export const systemInfoResponse = type({ export const systemInfoResponse = z.object({
capabilities: capabilitiesSchema, capabilities: capabilitiesSchema,
}); });
export type SystemInfoDto = typeof systemInfoResponse.infer; export type SystemInfoDto = z.infer<typeof systemInfoResponse>;
export const releaseInfoSchema = type({ export const releaseInfoSchema = z.object({
version: "string", version: z.string(),
url: "string", url: z.string(),
publishedAt: "string", publishedAt: z.string(),
body: "string", body: z.string(),
}); });
export const updateInfoResponse = type({ export const updateInfoResponse = z.object({
currentVersion: "string", currentVersion: z.string(),
latestVersion: "string", latestVersion: z.string(),
hasUpdate: "boolean", hasUpdate: z.boolean(),
missedReleases: releaseInfoSchema.array(), missedReleases: releaseInfoSchema.array(),
}); });
export type UpdateInfoDto = typeof updateInfoResponse.infer; export type UpdateInfoDto = z.infer<typeof updateInfoResponse>;
export const systemInfoDto = describeRoute({ export const systemInfoDto = describeRoute({
description: "Get system information including available capabilities", description: "Get system information including available capabilities",
@ -60,8 +60,8 @@ export const getUpdatesDto = describeRoute({
}, },
}); });
export const downloadResticPasswordBodySchema = type({ export const downloadResticPasswordBodySchema = z.object({
password: "string", password: z.string(),
}); });
export const downloadResticPasswordDto = describeRoute({ export const downloadResticPasswordDto = describeRoute({
@ -81,14 +81,14 @@ export const downloadResticPasswordDto = describeRoute({
}, },
}); });
export const registrationStatusResponse = type({ export const registrationStatusResponse = z.object({
enabled: "boolean", enabled: z.boolean(),
}); });
export type RegistrationStatusDto = typeof registrationStatusResponse.infer; export type RegistrationStatusDto = z.infer<typeof registrationStatusResponse>;
export const registrationStatusBody = type({ export const registrationStatusBody = z.object({
enabled: "boolean", enabled: z.boolean(),
}); });
export const getRegistrationStatusDto = describeRoute({ export const getRegistrationStatusDto = describeRoute({
@ -123,11 +123,11 @@ export const setRegistrationStatusDto = describeRoute({
}, },
}); });
export const devPanelResponse = type({ export const devPanelResponse = z.object({
enabled: "boolean", enabled: z.boolean(),
}); });
export type DevPanelDto = typeof devPanelResponse.infer; export type DevPanelDto = z.infer<typeof devPanelResponse>;
export const getDevPanelDto = describeRoute({ export const getDevPanelDto = describeRoute({
description: "Get the dev panel status", description: "Get the dev panel status",

View file

@ -110,8 +110,8 @@ export const volumeController = new Hono()
const shortId = asShortId(c.req.param("shortId")); const shortId = asShortId(c.req.param("shortId"));
const { path, ...query } = c.req.valid("query"); const { path, ...query } = c.req.valid("query");
const offset = Math.max(0, Number.parseInt(query.offset ?? "0", 10) || 0); const offset = Math.max(0, query.offset ?? 0);
const limit = Math.min(1000, Math.max(1, Number.parseInt(query.limit ?? "500", 10) || 500)); const limit = Math.min(1000, Math.max(1, query.limit ?? 500));
const result = await volumeService.listFiles(shortId, path, offset, limit); const result = await volumeService.listFiles(shortId, path, offset, limit);

View file

@ -1,28 +1,25 @@
import { type } from "arktype"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes"; import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes";
export const volumeSchema = type({ export const volumeSchema = z.object({
id: "number", id: z.number(),
shortId: "string", shortId: z.string(),
name: "string", name: z.string(),
type: type.valueOf(BACKEND_TYPES), type: z.enum(BACKEND_TYPES),
status: type.valueOf(BACKEND_STATUS), status: z.enum(BACKEND_STATUS),
lastError: "string | null", lastError: z.string().nullable(),
createdAt: "number", createdAt: z.number(),
updatedAt: "number", updatedAt: z.number(),
lastHealthCheck: "number", lastHealthCheck: z.number(),
config: volumeConfigSchema, config: volumeConfigSchema,
autoRemount: "boolean", autoRemount: z.boolean(),
}); });
export type VolumeDto = typeof volumeSchema.infer; export type VolumeDto = z.infer<typeof volumeSchema>;
/**
* List all volumes
*/
export const listVolumesResponse = volumeSchema.array(); export const listVolumesResponse = volumeSchema.array();
export type ListVolumesDto = typeof listVolumesResponse.infer; export type ListVolumesDto = z.infer<typeof listVolumesResponse>;
export const listVolumesDto = describeRoute({ export const listVolumesDto = describeRoute({
description: "List all volumes", description: "List all volumes",
@ -40,16 +37,13 @@ export const listVolumesDto = describeRoute({
}, },
}); });
/** export const createVolumeBody = z.object({
* Create a new volume name: z.string(),
*/
export const createVolumeBody = type({
name: "string",
config: volumeConfigSchema, config: volumeConfigSchema,
}); });
export const createVolumeResponse = volumeSchema; export const createVolumeResponse = volumeSchema;
export type CreateVolumeDto = typeof createVolumeResponse.infer; export type CreateVolumeDto = z.infer<typeof createVolumeResponse>;
export const createVolumeDto = describeRoute({ export const createVolumeDto = describeRoute({
description: "Create a new volume", description: "Create a new volume",
@ -67,13 +61,10 @@ export const createVolumeDto = describeRoute({
}, },
}); });
/** export const deleteVolumeResponse = z.object({
* Delete a volume message: z.string(),
*/
export const deleteVolumeResponse = type({
message: "string",
}); });
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer; export type DeleteVolumeDto = z.infer<typeof deleteVolumeResponse>;
export const deleteVolumeDto = describeRoute({ export const deleteVolumeDto = describeRoute({
description: "Delete a volume", description: "Delete a volume",
@ -91,21 +82,19 @@ export const deleteVolumeDto = describeRoute({
}, },
}); });
const statfsSchema = type({ const statfsSchema = z.object({
total: "number", total: z.number(),
used: "number", used: z.number(),
free: "number", free: z.number(),
}); });
const getVolumeResponse = type({ const getVolumeResponse = z.object({
volume: volumeSchema, volume: volumeSchema,
statfs: statfsSchema, statfs: statfsSchema,
}); });
export type GetVolumeDto = typeof getVolumeResponse.infer; export type GetVolumeDto = z.infer<typeof getVolumeResponse>;
/**
* Get a volume
*/
export const getVolumeDto = describeRoute({ export const getVolumeDto = describeRoute({
description: "Get a volume by name", description: "Get a volume by name",
operationId: "getVolume", operationId: "getVolume",
@ -125,19 +114,16 @@ export const getVolumeDto = describeRoute({
}, },
}); });
/** export const updateVolumeBody = z.object({
* Update a volume name: z.string().optional(),
*/ autoRemount: z.boolean().optional(),
export const updateVolumeBody = type({
name: "string?",
autoRemount: "boolean?",
config: volumeConfigSchema.optional(), config: volumeConfigSchema.optional(),
}); });
export type UpdateVolumeBody = typeof updateVolumeBody.infer; export type UpdateVolumeBody = z.infer<typeof updateVolumeBody>;
export const updateVolumeResponse = volumeSchema; export const updateVolumeResponse = volumeSchema;
export type UpdateVolumeDto = typeof updateVolumeResponse.infer; export type UpdateVolumeDto = z.infer<typeof updateVolumeResponse>;
export const updateVolumeDto = describeRoute({ export const updateVolumeDto = describeRoute({
description: "Update a volume's configuration", description: "Update a volume's configuration",
@ -158,18 +144,15 @@ export const updateVolumeDto = describeRoute({
}, },
}); });
/** export const testConnectionBody = z.object({
* Test connection
*/
export const testConnectionBody = type({
config: volumeConfigSchema, config: volumeConfigSchema,
}); });
export const testConnectionResponse = type({ export const testConnectionResponse = z.object({
success: "boolean", success: z.boolean(),
message: "string", message: z.string(),
}); });
export type TestConnectionDto = typeof testConnectionResponse.infer; export type TestConnectionDto = z.infer<typeof testConnectionResponse>;
export const testConnectionDto = describeRoute({ export const testConnectionDto = describeRoute({
description: "Test connection to backend", description: "Test connection to backend",
@ -187,14 +170,11 @@ export const testConnectionDto = describeRoute({
}, },
}); });
/** export const mountVolumeResponse = z.object({
* Mount volume error: z.string().optional(),
*/ status: z.enum(BACKEND_STATUS),
export const mountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
}); });
export type MountVolumeDto = typeof mountVolumeResponse.infer; export type MountVolumeDto = z.infer<typeof mountVolumeResponse>;
export const mountVolumeDto = describeRoute({ export const mountVolumeDto = describeRoute({
description: "Mount a volume", description: "Mount a volume",
@ -212,14 +192,11 @@ export const mountVolumeDto = describeRoute({
}, },
}); });
/** export const unmountVolumeResponse = z.object({
* Unmount volume error: z.string().optional(),
*/ status: z.enum(BACKEND_STATUS),
export const unmountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
}); });
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer; export type UnmountVolumeDto = z.infer<typeof unmountVolumeResponse>;
export const unmountVolumeDto = describeRoute({ export const unmountVolumeDto = describeRoute({
description: "Unmount a volume", description: "Unmount a volume",
@ -237,11 +214,11 @@ export const unmountVolumeDto = describeRoute({
}, },
}); });
export const healthCheckResponse = type({ export const healthCheckResponse = z.object({
error: "string?", error: z.string().optional(),
status: type.valueOf(BACKEND_STATUS), status: z.enum(BACKEND_STATUS),
}); });
export type HealthCheckDto = typeof healthCheckResponse.infer; export type HealthCheckDto = z.infer<typeof healthCheckResponse>;
export const healthCheckDto = describeRoute({ export const healthCheckDto = describeRoute({
description: "Perform a health check on a volume", description: "Perform a health check on a volume",
@ -262,31 +239,28 @@ export const healthCheckDto = describeRoute({
}, },
}); });
/** const fileEntrySchema = z.object({
* List files in a volume name: z.string(),
*/ path: z.string(),
const fileEntrySchema = type({ type: z.enum(["file", "directory"]),
name: "string", size: z.number().optional(),
path: "string", modifiedAt: z.number().optional(),
type: type.enumerated("file", "directory"),
size: "number?",
modifiedAt: "number?",
}); });
export const listFilesResponse = type({ export const listFilesResponse = z.object({
files: fileEntrySchema.array(), files: fileEntrySchema.array(),
path: "string", path: z.string(),
offset: "number", offset: z.number(),
limit: "number", limit: z.number(),
total: "number", total: z.number(),
hasMore: "boolean", hasMore: z.boolean(),
}); });
export type ListFilesDto = typeof listFilesResponse.infer; export type ListFilesDto = z.infer<typeof listFilesResponse>;
export const listFilesQuery = type({ export const listFilesQuery = z.object({
path: "string?", path: z.string().optional(),
offset: "string.integer?", offset: z.coerce.number().int().optional(),
limit: "string.integer?", limit: z.coerce.number().int().optional(),
}); });
export const listFilesDto = describeRoute({ export const listFilesDto = describeRoute({
@ -305,14 +279,11 @@ export const listFilesDto = describeRoute({
}, },
}); });
/** export const browseFilesystemResponse = z.object({
* Browse filesystem directories
*/
export const browseFilesystemResponse = type({
directories: fileEntrySchema.array(), directories: fileEntrySchema.array(),
path: "string", path: z.string(),
}); });
export type BrowseFilesystemDto = typeof browseFilesystemResponse.infer; export type BrowseFilesystemDto = z.infer<typeof browseFilesystemResponse>;
export const browseFilesystemDto = describeRoute({ export const browseFilesystemDto = describeRoute({
description: "Browse directories on the host filesystem", description: "Browse directories on the host filesystem",

View file

@ -16,7 +16,6 @@ import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes"; import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
import { type } from "arktype";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
import { isNodeJSErrnoException } from "~/server/utils/fs"; import { isNodeJSErrnoException } from "~/server/utils/fs";
import { asShortId, type ShortId } from "~/server/utils/branded"; import { asShortId, type ShortId } from "~/server/utils/branded";
@ -201,10 +200,11 @@ const updateVolume = async (shortId: ShortId, volumeData: UpdateVolumeBody) => {
await backend.unmount(); await backend.unmount();
} }
const newConfig = volumeConfigSchema(volumeData.config || existing.config); const newConfigResult = volumeConfigSchema.safeParse(volumeData.config || existing.config);
if (newConfig instanceof type.errors) { if (!newConfigResult.success) {
throw new BadRequestError("Invalid volume configuration"); throw new BadRequestError("Invalid volume configuration");
} }
const newConfig = newConfigResult.data;
const encryptedConfig = await encryptSensitiveFields(newConfig); const encryptedConfig = await encryptSensitiveFields(newConfig);

View file

@ -1,7 +1,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { type } from "arktype";
import { throttle } from "es-toolkit"; import { throttle } from "es-toolkit";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { import {
@ -108,11 +107,11 @@ export const backup = async (
if (options.onProgress) { if (options.onProgress) {
try { try {
const jsonData = JSON.parse(data); const jsonData = JSON.parse(data);
const progress = resticBackupProgressSchema(jsonData); const progressResult = resticBackupProgressSchema.safeParse(jsonData);
if (!(progress instanceof type.errors)) { if (progressResult.success) {
options.onProgress(progress); options.onProgress(progressResult.data);
} else { } else {
logger.error(progress.summary); logger.error(progressResult.error.message);
} }
} catch { } catch {
// Ignore JSON parse errors for non-JSON lines // Ignore JSON parse errors for non-JSON lines
@ -168,12 +167,12 @@ export const backup = async (
} }
logger.debug(`Restic backup output last line: ${JSON.stringify(summaryLine)}`); logger.debug(`Restic backup output last line: ${JSON.stringify(summaryLine)}`);
const result = resticBackupOutputSchema(summaryLine); const result = resticBackupOutputSchema.safeParse(summaryLine);
if (result instanceof type.errors) { if (!result.success) {
logger.error(`Restic backup output validation failed: ${result.summary}`); logger.error(`Restic backup output validation failed: ${result.error.message}`);
return { result: null, exitCode: res.exitCode }; return { result: null, exitCode: res.exitCode };
} }
return { result, exitCode: res.exitCode }; return { result: result.data, exitCode: res.exitCode };
}; };

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
import type { RepositoryConfig } from "~/schemas/restic"; import type { RepositoryConfig } from "~/schemas/restic";
import { ResticError } from "~/server/utils/errors"; import { ResticError } from "~/server/utils/errors";
import { logger } from "~/server/utils/logger"; import { logger } from "~/server/utils/logger";
@ -8,40 +8,54 @@ import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url"; import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
const lsNodeSchema = type({ const lsNodeSchema = z.object({
name: "string", name: z.string(),
type: "string", type: z.string(),
path: "string", path: z.string(),
uid: "number?", uid: z.number().optional(),
gid: "number?", gid: z.number().optional(),
size: "number?", size: z.number().optional(),
mode: "number?", mode: z.number().optional(),
mtime: "string?", mtime: z.string().optional(),
atime: "string?", atime: z.string().optional(),
ctime: "string?", ctime: z.string().optional(),
struct_type: "'node'", struct_type: z.literal("node"),
}); });
const lsSnapshotInfoSchema = type({ const lsSnapshotInfoSchema = z.object({
time: "string", time: z.string(),
parent: "string?", parent: z.string().optional(),
tree: "string", tree: z.string(),
paths: "string[]", paths: z.array(z.string()),
hostname: "string", hostname: z.string(),
username: "string?", username: z.string().optional(),
id: "string", id: z.string(),
short_id: "string", short_id: z.string(),
struct_type: "'snapshot'", struct_type: z.literal("snapshot"),
message_type: "'snapshot'", message_type: z.literal("snapshot"),
}); });
type LsNode = z.infer<typeof lsNodeSchema>;
type LsSnapshotInfo = z.infer<typeof lsSnapshotInfoSchema>;
export type ResticLsResult = {
snapshot: LsSnapshotInfo | null;
nodes: LsNode[];
pagination: {
offset: number;
limit: number;
total: number;
hasMore: boolean;
};
};
export const ls = async ( export const ls = async (
config: RepositoryConfig, config: RepositoryConfig,
snapshotId: string, snapshotId: string,
organizationId: string, organizationId: string,
path?: string, path?: string,
options?: { offset?: number; limit?: number }, options?: { offset?: number; limit?: number },
) => { ): Promise<ResticLsResult> => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, organizationId); const env = await buildEnv(config, organizationId);
@ -53,8 +67,8 @@ export const ls = async (
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
let snapshot: typeof lsSnapshotInfoSchema.infer | null = null; let snapshot: LsSnapshotInfo | null = null;
const nodes: Array<typeof lsNodeSchema.infer> = []; const nodes: LsNode[] = [];
let totalNodes = 0; let totalNodes = 0;
let isFirstLine = true; let isFirstLine = true;
let hasMore = false; let hasMore = false;
@ -77,21 +91,21 @@ export const ls = async (
if (isFirstLine) { if (isFirstLine) {
isFirstLine = false; isFirstLine = false;
const snapshotValidation = lsSnapshotInfoSchema(data); const snapshotValidation = lsSnapshotInfoSchema.safeParse(data);
if (!(snapshotValidation instanceof type.errors)) { if (snapshotValidation.success) {
snapshot = snapshotValidation; snapshot = snapshotValidation.data;
} }
return; return;
} }
const nodeValidation = lsNodeSchema(data); const nodeValidation = lsNodeSchema.safeParse(data);
if (nodeValidation instanceof type.errors) { if (!nodeValidation.success) {
logger.warn(`Skipping invalid node: ${nodeValidation.summary}`); logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`);
return; return;
} }
if (totalNodes >= offset && totalNodes < offset + limit) { if (totalNodes >= offset && totalNodes < offset + limit) {
nodes.push(nodeValidation); nodes.push(nodeValidation.data);
} }
totalNodes++; totalNodes++;
@ -116,7 +130,7 @@ export const ls = async (
} }
return { return {
snapshot: snapshot as typeof lsSnapshotInfoSchema.infer | null, snapshot,
nodes, nodes,
pagination: { pagination: {
offset, offset,

View file

@ -1,5 +1,5 @@
import path from "node:path"; import path from "node:path";
import { type } from "arktype"; import { z } from "zod";
import { throttle } from "es-toolkit"; import { throttle } from "es-toolkit";
import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic"; import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic";
import type { ResticRestoreOutputDto } from "~/schemas/restic-dto"; import type { ResticRestoreOutputDto } from "~/schemas/restic-dto";
@ -13,17 +13,17 @@ import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url"; import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
const restoreProgressSchema = type({ const restoreProgressSchema = z.object({
message_type: "'status' | 'summary'", message_type: z.enum(["status", "summary"]),
seconds_elapsed: "number", seconds_elapsed: z.number(),
percent_done: "number = 0", percent_done: z.number().default(0),
total_files: "number", total_files: z.number(),
files_restored: "number = 0", files_restored: z.number().default(0),
total_bytes: "number = 0", total_bytes: z.number().default(0),
bytes_restored: "number = 0", bytes_restored: z.number().default(0),
}); });
export type RestoreProgress = typeof restoreProgressSchema.infer; export type RestoreProgress = z.infer<typeof restoreProgressSchema>;
export const restore = async ( export const restore = async (
config: RepositoryConfig, config: RepositoryConfig,
@ -100,11 +100,11 @@ export const restore = async (
if (options.onProgress) { if (options.onProgress) {
try { try {
const jsonData = JSON.parse(data); const jsonData = JSON.parse(data);
const progress = restoreProgressSchema(jsonData); const progress = restoreProgressSchema.safeParse(jsonData);
if (!(progress instanceof type.errors)) { if (progress.success) {
options.onProgress(progress); options.onProgress(progress.data);
} else { } else {
logger.error(progress.summary); logger.error(progress.error.message);
} }
} catch { } catch {
// Ignore JSON parse errors for non-JSON lines // Ignore JSON parse errors for non-JSON lines
@ -142,10 +142,10 @@ export const restore = async (
} }
logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`); logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`);
const result = resticRestoreOutputSchema(summaryLine); const result = resticRestoreOutputSchema.safeParse(summaryLine);
if (result instanceof type.errors) { if (!result.success) {
logger.warn(`Restic restore output validation failed: ${result.summary}`); logger.warn(`Restic restore output validation failed: ${result.error.message}`);
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
const fallback: ResticRestoreOutputDto = { const fallback: ResticRestoreOutputDto = {
message_type: "summary" as const, message_type: "summary" as const,
@ -159,8 +159,8 @@ export const restore = async (
} }
logger.info( logger.info(
`Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_skipped} skipped`, `Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.data.files_restored} restored, ${result.data.files_skipped} skipped`,
); );
return result; return result.data;
}; };

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
import type { RepositoryConfig } from "~/schemas/restic"; import type { RepositoryConfig } from "~/schemas/restic";
import { resticSnapshotSummarySchema } from "~/schemas/restic-dto"; import { resticSnapshotSummarySchema } from "~/schemas/restic-dto";
import { logger } from "~/server/utils/logger"; import { logger } from "~/server/utils/logger";
@ -8,18 +8,18 @@ import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url"; import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
const snapshotInfoSchema = type({ const snapshotInfoSchema = z.object({
gid: "number?", gid: z.number().optional(),
hostname: "string", hostname: z.string(),
id: "string", id: z.string(),
parent: "string?", parent: z.string().optional(),
paths: "string[]", paths: z.array(z.string()),
program_version: "string?", program_version: z.string().optional(),
short_id: "string", short_id: z.string(),
time: "string", time: z.string(),
uid: "number?", uid: z.number().optional(),
username: "string?", username: z.string().optional(),
tags: "string[]?", tags: z.array(z.string()).optional(),
summary: resticSnapshotSummarySchema.optional(), summary: resticSnapshotSummarySchema.optional(),
}); });
@ -47,12 +47,12 @@ export const snapshots = async (config: RepositoryConfig, options: { tags?: stri
throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`); throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`);
} }
const result = snapshotInfoSchema.array()(JSON.parse(res.stdout)); const result = snapshotInfoSchema.array().safeParse(JSON.parse(res.stdout));
if (result instanceof type.errors) { if (!result.success) {
logger.error(`Restic snapshots output validation failed: ${result.summary}`); logger.error(`Restic snapshots output validation failed: ${result.error.message}`);
throw new Error(`Restic snapshots output validation failed: ${result.summary}`); throw new Error(`Restic snapshots output validation failed: ${result.error.message}`);
} }
return result; return result.data;
}; };

View file

@ -1,4 +1,3 @@
import { type } from "arktype";
import type { RepositoryConfig } from "~/schemas/restic"; import type { RepositoryConfig } from "~/schemas/restic";
import { resticStatsSchema } from "~/schemas/restic-dto"; import { resticStatsSchema } from "~/schemas/restic-dto";
import { safeJsonParse } from "~/server/utils/json"; import { safeJsonParse } from "~/server/utils/json";
@ -26,12 +25,12 @@ export const stats = async (config: RepositoryConfig, options: { organizationId:
} }
const parsedJson = safeJsonParse<unknown>(res.stdout); const parsedJson = safeJsonParse<unknown>(res.stdout);
const result = resticStatsSchema(parsedJson); const result = resticStatsSchema.safeParse(parsedJson);
if (result instanceof type.errors) { if (!result.success) {
logger.error(`Restic stats output validation failed: ${result.summary}`); logger.error(`Restic stats output validation failed: ${result.error.message}`);
throw new Error(`Restic stats output validation failed: ${result.summary}`); throw new Error(`Restic stats output validation failed: ${result.error.message}`);
} }
return result; return result.data;
}; };

View file

@ -34,7 +34,6 @@
"@tanstack/react-router": "^1.166.2", "@tanstack/react-router": "^1.166.2",
"@tanstack/react-router-ssr-query": "^1.166.2", "@tanstack/react-router-ssr-query": "^1.166.2",
"@tanstack/react-start": "^1.166.2", "@tanstack/react-start": "^1.166.2",
"arktype": "^2.2.0",
"better-auth": "^1.5.4", "better-auth": "^1.5.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -69,6 +68,7 @@
"tiny-typed-emitter": "^2.1.0", "tiny-typed-emitter": "^2.1.0",
"web-haptics": "^0.0.6", "web-haptics": "^0.0.6",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"zod": "^4.0.1",
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-typescript": "^7.28.5", "@babel/preset-typescript": "^7.28.5",

View file

@ -55,7 +55,6 @@
"@tanstack/react-router": "^1.166.2", "@tanstack/react-router": "^1.166.2",
"@tanstack/react-router-ssr-query": "^1.166.2", "@tanstack/react-router-ssr-query": "^1.166.2",
"@tanstack/react-start": "^1.166.2", "@tanstack/react-start": "^1.166.2",
"arktype": "^2.2.0",
"better-auth": "^1.5.4", "better-auth": "^1.5.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -89,7 +88,8 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tiny-typed-emitter": "^2.1.0", "tiny-typed-emitter": "^2.1.0",
"web-haptics": "^0.0.6", "web-haptics": "^0.0.6",
"yaml": "^2.8.2" "yaml": "^2.8.2",
"zod": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-typescript": "^7.28.5", "@babel/preset-typescript": "^7.28.5",