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 GitHub
parent dd8f471d60
commit df312cd419
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 3776 additions and 3795 deletions

View file

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

View file

@ -457,7 +457,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
/**
* 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
{
queryFn: async ({ pageParam, queryKey, signal }) => {
@ -747,7 +747,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
/**
* 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
{
queryFn: async ({ pageParam, queryKey, signal }) => {

View file

@ -38,7 +38,7 @@ export const createClient = (config: Config = {}): Client => {
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
serializedBody: undefined as string | undefined,
};
if (opts.security) {
@ -53,7 +53,7 @@ export const createClient = (config: Config = {}): Client => {
}
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
@ -259,8 +259,10 @@ export const createClient = (config: Config = {}): Client => {
});
};
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return {
buildUrl,
buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
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> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T> | Promise<Config<Required<ClientOptions> & T>>;
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
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 BodySerializer = (body: any) => any;
export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
@ -40,12 +40,10 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
bodySerializer: (body: unknown): 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) {
return;
}
@ -61,15 +59,15 @@ export const formDataBodySerializer = {
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
bodySerializer: (body: unknown): string => {
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) {
return;
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -24,7 +24,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
return await queryClient.ensureQueryData(
listFilesOptions({
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 { type } from "arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@ -16,13 +15,17 @@ import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
import { z } from "zod";
const loginSchema = type({
username: "2<=string<=50",
password: "string>=1",
const loginSchema = z.object({
username: z
.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 = {
error?: string;
@ -40,7 +43,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema),
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",

View file

@ -1,5 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
@ -19,24 +18,28 @@ import { authClient } from "~/client/lib/auth-client";
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { z } from "zod";
export const clientMiddleware = [authMiddleware];
const onboardingSchema = type({
username: type("2<=string<=30").pipe(normalizeUsername),
email: type("string.email").pipe((str) => str.trim().toLowerCase()),
password: "string>=8",
confirmPassword: "string>=1",
const onboardingSchema = z.object({
username: z.string().min(2).max(30).transform(normalizeUsername),
email: z
.string()
.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() {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema),
resolver: zodResolver(onboardingSchema),
defaultValues: {
username: "",
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 { useForm } from "react-hook-form";
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 { RetentionSection } from "./retention-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";
export type { BackupScheduleFormValues };
@ -29,7 +29,7 @@ type Props = {
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof import("./types").internalFormSchema),
resolver: zodResolver(internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues),
});

View file

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

View file

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

View file

@ -1,8 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import {
Form,
FormControl,
@ -14,7 +13,17 @@ import {
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
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 {
CustomForm,
@ -28,12 +37,21 @@ import {
TelegramForm,
} from "./notification-forms";
export const formSchema = type({
name: "2<=string<=32",
}).and(notificationConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
const baseFields = { name: z.string().min(2).max(32) };
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 = {
onSubmit: (values: NotificationFormValues) => void;
@ -106,7 +124,7 @@ const defaultValuesForType = {
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || {
name: "",
},

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import {
CreateRepositoryForm,
formSchema,
type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form";
import {
@ -35,11 +36,11 @@ const riskyLocationFieldsByBackend = {
rclone: ["remote", "path"],
} as const;
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => {
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryConfig): boolean => {
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
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 { name, compressionMode, ...config } = formSchema.parse(values);
updateRepository.mutate({
path: { shortId: repositoryId },
body: {
name: values.name,
compressionMode: values.compressionMode,
config: values,
name,
compressionMode,
config,
},
});
};
const handleSubmit = (values: RepositoryFormValues) => {
const nextConfig = values;
const { name: _name, compressionMode: _compressionMode, ...nextConfig } = formSchema.parse(values);
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
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 { type } from "arktype";
import { Plus, UserPlus } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
@ -20,15 +20,15 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { authClient } from "~/client/lib/auth-client";
const createUserSchema = type({
name: "string>=1",
username: "string>=1",
email: "string",
password: "string>=8",
role: "'user'|'admin'",
const createUserSchema = z.object({
name: z.string().min(1),
username: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(["user", "admin"]),
});
type CreateUserFormValues = typeof createUserSchema.infer;
type CreateUserFormValues = z.infer<typeof createUserSchema>;
interface CreateUserDialogProps {
onUserCreated?: () => void;
@ -38,7 +38,7 @@ export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const form = useForm<CreateUserFormValues>({
resolver: arktypeResolver(createUserSchema),
resolver: zodResolver(createUserSchema),
defaultValues: {
name: "",
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 { useNavigate } from "@tanstack/react-router";
import { type } from "arktype";
import { ShieldCheck, Plus } from "lucide-react";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
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 { useOrganizationContext } from "~/client/hooks/use-org-context";
const ssoProviderSchema = type({
providerId: "string>=1",
issuer: "string>=1",
domain: "string>=1",
clientId: "string>=1",
clientSecret: "string>=1",
discoveryEndpoint: "string>=1",
linkMatchingEmails: "boolean",
const ssoProviderSchema = z.object({
providerId: z.string().min(1),
issuer: z.string().min(1),
domain: z.string().min(1),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
discoveryEndpoint: z.string().min(1),
linkMatchingEmails: z.boolean(),
});
type ProviderForm = typeof ssoProviderSchema.infer;
type ProviderForm = z.infer<typeof ssoProviderSchema>;
export function CreateSsoProviderPage() {
const navigate = useNavigate();
@ -42,7 +42,7 @@ export function CreateSsoProviderPage() {
const { activeOrganization } = useOrganizationContext();
const form = useForm<ProviderForm>({
resolver: arktypeResolver(ssoProviderSchema),
resolver: zodResolver(ssoProviderSchema),
defaultValues: {
providerId: "",
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 { type } from "arktype";
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button";
import {
Form,
@ -18,19 +17,31 @@ import {
} from "../../../components/ui/form";
import { Input } from "../../../components/ui/input";
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 { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
export const formSchema = type({
name: "2<=string<=32",
}).and(volumeConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export const formSchema = z.discriminatedUnion("backend", [
directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
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 = {
onSubmit: (values: FormValues) => void;
@ -52,7 +63,7 @@ const defaultValuesForType = {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || {
name: "",
backend: "directory",
@ -89,15 +100,22 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const handleTestConnection = async () => {
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 (
formValues.backend === "nfs" ||
formValues.backend === "smb" ||
formValues.backend === "webdav" ||
formValues.backend === "sftp"
parsedConfig.data.backend === "nfs" ||
parsedConfig.data.backend === "smb" ||
parsedConfig.data.backend === "webdav" ||
parsedConfig.data.backend === "sftp"
) {
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 { toast } from "sonner";
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 { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
@ -23,10 +23,12 @@ export function CreateVolumePage() {
});
const handleSubmit = (values: FormValues) => {
const { name, ...config } = formSchema.parse(values);
createVolume.mutate({
body: {
config: values,
name: values.name,
config,
name,
},
});
};

View file

@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
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 {
AlertDialog,
AlertDialogAction,
@ -102,9 +102,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const confirmUpdate = () => {
if (pendingValues) {
const { name, ...config } = formSchema.parse(pendingValues);
updateMutation.mutate({
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 { type } from "arktype";
import { z } from "zod";
import { LoginPage } from "~/client/modules/auth/routes/login";
export const Route = createFileRoute("/(auth)/login/error")({
component: RouteComponent,
errorComponent: () => <div>Failed to load login error</div>,
validateSearch: type({ error: "string?" }),
validateSearch: z.object({ error: z.string().optional() }),
head: () => ({
meta: [
{ title: "Zerobyte - Login Error" },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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 { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
@ -13,7 +13,7 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
return res;
},
validateSearch: type({ tab: "string?" }),
validateSearch: z.object({ tab: z.string().optional() }),
staticData: {
breadcrumb: (match) => [
{ 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";
export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'");
export const restoreEventStatusSchema = type("'success' | 'error'");
export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
export const restoreEventStatusSchema = z.enum(["success", "error"]);
const backupEventBaseSchema = type({
scheduleId: "string",
volumeName: "string",
repositoryName: "string",
const backupEventBaseSchema = z.object({
scheduleId: z.string(),
volumeName: z.string(),
repositoryName: z.string(),
});
const organizationScopedSchema = type({
organizationId: "string",
const organizationScopedSchema = z.object({
organizationId: z.string(),
});
const restoreEventBaseSchema = type({
repositoryId: "string",
snapshotId: "string",
const restoreEventBaseSchema = z.object({
repositoryId: z.string(),
snapshotId: z.string(),
});
const dumpStartedEventSchema = type({
repositoryId: "string",
snapshotId: "string",
path: "string",
filename: "string",
const dumpStartedEventSchema = z.object({
repositoryId: z.string(),
snapshotId: z.string(),
path: z.string(),
filename: z.string(),
});
const restoreProgressMetricsSchema = type({
seconds_elapsed: "number",
percent_done: "number",
total_files: "number",
files_restored: "number",
total_bytes: "number",
bytes_restored: "number",
const restoreProgressMetricsSchema = z.object({
seconds_elapsed: z.number(),
percent_done: z.number(),
total_files: z.number(),
files_restored: z.number(),
total_bytes: z.number(),
bytes_restored: z.number(),
});
export const backupStartedEventSchema = backupEventBaseSchema;
export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema);
export const backupProgressEventSchema = backupEventBaseSchema.extend(resticBackupProgressMetricsSchema.shape);
export const backupCompletedEventSchema = backupEventBaseSchema.and(
type({
status: backupEventStatusSchema,
summary: resticBackupRunSummarySchema.optional(),
}),
);
export const backupCompletedEventSchema = backupEventBaseSchema.extend({
status: backupEventStatusSchema,
summary: resticBackupRunSummarySchema.optional(),
});
export const restoreStartedEventSchema = restoreEventBaseSchema;
export const restoreProgressEventSchema = restoreEventBaseSchema.and(restoreProgressMetricsSchema);
export const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgressMetricsSchema.shape);
export const restoreCompletedEventSchema = restoreEventBaseSchema.and(
type({ status: restoreEventStatusSchema, error: "string?" }),
);
export const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
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 BackupStartedEventDto = typeof backupStartedEventSchema.infer;
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer;
export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer;
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer;
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer;
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer;
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer;
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer;
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer;
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer;
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer;
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer;
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer;
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer;
export type BackupEventStatusDto = z.infer<typeof backupEventStatusSchema>;
export type BackupStartedEventDto = z.infer<typeof backupStartedEventSchema>;
export type BackupProgressEventDto = z.infer<typeof backupProgressEventSchema>;
export type BackupCompletedEventDto = z.infer<typeof backupCompletedEventSchema>;
export type RestoreStartedEventDto = z.infer<typeof restoreStartedEventSchema>;
export type RestoreProgressEventDto = z.infer<typeof restoreProgressEventSchema>;
export type RestoreCompletedEventDto = z.infer<typeof restoreCompletedEventSchema>;
export type DumpStartedEventDto = z.infer<typeof dumpStartedEventSchema>;
export type ServerBackupStartedEventDto = z.infer<typeof serverBackupStartedEventSchema>;
export type ServerBackupProgressEventDto = z.infer<typeof serverBackupProgressEventSchema>;
export type ServerBackupCompletedEventDto = z.infer<typeof serverBackupCompletedEventSchema>;
export type ServerRestoreStartedEventDto = z.infer<typeof serverRestoreStartedEventSchema>;
export type ServerRestoreProgressEventDto = z.infer<typeof serverRestoreProgressEventSchema>;
export type ServerRestoreCompletedEventDto = z.infer<typeof serverRestoreCompletedEventSchema>;
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 = {
email: "email",
@ -14,96 +14,98 @@ export const NOTIFICATION_TYPES = {
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
export const emailNotificationConfigSchema = type({
type: "'email'",
smtpHost: "string",
smtpPort: "1 <= number <= 65535",
username: "string?",
password: "string?",
from: "string",
fromName: "string?",
to: "string[]",
useTLS: "boolean",
export const emailNotificationConfigSchema = z.object({
type: z.literal("email"),
smtpHost: z.string().min(1),
smtpPort: z.number().int().min(1).max(65535),
username: z.string().optional(),
password: z.string().optional(),
from: z.string().min(1),
fromName: z.string().optional(),
to: z.array(z.string()),
useTLS: z.boolean(),
});
export const slackNotificationConfigSchema = type({
type: "'slack'",
webhookUrl: "string",
channel: "string?",
username: "string?",
iconEmoji: "string?",
export const slackNotificationConfigSchema = z.object({
type: z.literal("slack"),
webhookUrl: z.string().min(1),
channel: z.string().optional(),
username: z.string().optional(),
iconEmoji: z.string().optional(),
});
export const discordNotificationConfigSchema = type({
type: "'discord'",
webhookUrl: "string",
username: "string?",
avatarUrl: "string?",
threadId: "string?",
export const discordNotificationConfigSchema = z.object({
type: z.literal("discord"),
webhookUrl: z.string().min(1),
username: z.string().optional(),
avatarUrl: z.string().optional(),
threadId: z.string().optional(),
});
export const gotifyNotificationConfigSchema = type({
type: "'gotify'",
serverUrl: "string",
token: "string",
path: "string?",
priority: "0 <= number <= 10",
export const gotifyNotificationConfigSchema = z.object({
type: z.literal("gotify"),
serverUrl: z.string().min(1),
token: z.string().min(1),
path: z.string().optional(),
priority: z.number().min(0).max(10),
});
export const ntfyNotificationConfigSchema = type({
type: "'ntfy'",
serverUrl: "string?",
topic: "string",
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
username: "string?",
password: "string?",
accessToken: "string?",
export const ntfyNotificationConfigSchema = z.object({
type: z.literal("ntfy"),
serverUrl: z.string().optional(),
topic: z.string().min(1),
priority: z.enum(["max", "high", "default", "low", "min"]),
username: z.string().optional(),
password: z.string().optional(),
accessToken: z.string().optional(),
});
export const pushoverNotificationConfigSchema = type({
type: "'pushover'",
userKey: "string",
apiToken: "string",
devices: "string?",
priority: "-1 | 0 | 1",
export const pushoverNotificationConfigSchema = z.object({
type: z.literal("pushover"),
userKey: z.string().min(1),
apiToken: z.string().min(1),
devices: z.string().optional(),
priority: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
});
export const telegramNotificationConfigSchema = type({
type: "'telegram'",
botToken: "string",
chatId: "string",
threadId: "string?",
export const telegramNotificationConfigSchema = z.object({
type: z.literal("telegram"),
botToken: z.string().min(1),
chatId: z.string().min(1),
threadId: z.string().optional(),
});
export const genericNotificationConfigSchema = type({
type: "'generic'",
url: "string",
method: "'GET' | 'POST'",
contentType: "string?",
headers: "string[]?",
useJson: "boolean?",
titleKey: "string?",
messageKey: "string?",
export const genericNotificationConfigSchema = z.object({
type: z.literal("generic"),
url: z.string().min(1),
method: z.enum(["GET", "POST"]),
contentType: z.string().optional(),
headers: z.array(z.string()).optional(),
useJson: z.boolean().optional(),
titleKey: z.string().optional(),
messageKey: z.string().optional(),
});
export const customNotificationConfigSchema = type({
type: "'custom'",
shoutrrrUrl: "string",
export const customNotificationConfigSchema = z.object({
type: z.literal("custom"),
shoutrrrUrl: z.string().min(1),
});
export const notificationConfigSchemaBase = emailNotificationConfigSchema
.or(slackNotificationConfigSchema)
.or(discordNotificationConfigSchema)
.or(gotifyNotificationConfigSchema)
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(genericNotificationConfigSchema)
.or(customNotificationConfigSchema);
export const notificationConfigSchemaBase = z.discriminatedUnion("type", [
emailNotificationConfigSchema,
slackNotificationConfigSchema,
discordNotificationConfigSchema,
gotifyNotificationConfigSchema,
ntfyNotificationConfigSchema,
pushoverNotificationConfigSchema,
telegramNotificationConfigSchema,
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 = {
start: "start",

View file

@ -1,82 +1,72 @@
import { type } from "arktype";
import { z } from "zod";
export const resticSummaryBaseSchema = type({
files_new: "number",
files_changed: "number",
files_unmodified: "number",
dirs_new: "number",
dirs_changed: "number",
dirs_unmodified: "number",
data_blobs: "number",
tree_blobs: "number",
data_added: "number",
data_added_packed: "number?",
total_files_processed: "number",
total_bytes_processed: "number",
export const resticSummaryBaseSchema = z.object({
files_new: z.number(),
files_changed: z.number(),
files_unmodified: z.number(),
dirs_new: z.number(),
dirs_changed: z.number(),
dirs_unmodified: z.number(),
data_blobs: z.number(),
tree_blobs: z.number(),
data_added: z.number(),
data_added_packed: z.number().optional(),
total_files_processed: z.number(),
total_bytes_processed: z.number(),
});
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and(
type({
backup_start: "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",
percent_done: "number",
total_files: "number",
files_done: "number",
total_bytes: "number",
bytes_done: "number",
current_files: type("string")
.array()
.default(() => []),
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.extend({
backup_start: z.string(),
backup_end: z.string(),
});
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and(
type({
message_type: "'status'",
}),
);
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 resticBackupRunSummarySchema = resticSummaryBaseSchema.extend({
total_duration: z.number(),
snapshot_id: z.string(),
});
export const resticStatsSchema = type({
total_size: "number = 0",
total_uncompressed_size: "number = 0",
compression_ratio: "number = 0",
compression_progress: "number = 0",
compression_space_saving: "number = 0",
snapshots_count: "number = 0",
export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
message_type: z.literal("summary"),
});
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer;
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer;
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer;
export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer;
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer;
export const resticBackupProgressMetricsSchema = z.object({
seconds_elapsed: z.number(),
percent_done: z.number(),
total_files: z.number(),
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 type ResticStatsDto = typeof resticStatsSchema.infer;
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.extend({
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 = {
local: "local",
@ -21,98 +21,120 @@ export const BANDWIDTH_UNITS = {
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
export const bandwidthLimitSchema = type({
enabled: "boolean = false",
value: "number > 0 = 1",
unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"),
const bandwidthUnitSchema = z.enum(["Kbps", "Mbps", "Gbps"]);
export const bandwidthLimitSchema = z.object({
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 = type({
isExistingRepository: "boolean?",
customPassword: "string?",
cacert: "string?",
insecureTls: "boolean?",
// Bandwidth controls
const baseRepositoryConfigSchema = z.object({
isExistingRepository: z.boolean().optional(),
customPassword: z.string().optional(),
cacert: z.string().optional(),
insecureTls: z.boolean().optional(),
uploadLimit: bandwidthLimitSchema.optional(),
downloadLimit: bandwidthLimitSchema.optional(),
});
export const s3RepositoryConfigSchema = type({
backend: "'s3'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
}).and(baseRepositoryConfigSchema);
export const s3RepositoryConfigSchema = z
.object({
backend: z.literal("s3"),
endpoint: z.string().min(1),
bucket: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const r2RepositoryConfigSchema = type({
backend: "'r2'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
}).and(baseRepositoryConfigSchema);
export const r2RepositoryConfigSchema = z
.object({
backend: z.literal("r2"),
endpoint: z.string().min(1),
bucket: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const localRepositoryConfigSchema = type({
backend: "'local'",
path: "string",
}).and(baseRepositoryConfigSchema);
export const localRepositoryConfigSchema = z
.object({
backend: z.literal("local"),
path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const gcsRepositoryConfigSchema = type({
backend: "'gcs'",
bucket: "string",
projectId: "string",
credentialsJson: "string",
}).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigSchema = z
.object({
backend: z.literal("gcs"),
bucket: z.string().min(1),
projectId: z.string().min(1),
credentialsJson: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const azureRepositoryConfigSchema = type({
backend: "'azure'",
container: "string",
accountName: "string",
accountKey: "string",
endpointSuffix: "string?",
}).and(baseRepositoryConfigSchema);
export const azureRepositoryConfigSchema = z
.object({
backend: z.literal("azure"),
container: z.string().min(1),
accountName: z.string().min(1),
accountKey: z.string().min(1),
endpointSuffix: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const rcloneRepositoryConfigSchema = type({
backend: "'rclone'",
remote: "string",
path: "string",
}).and(baseRepositoryConfigSchema);
export const rcloneRepositoryConfigSchema = z
.object({
backend: z.literal("rclone"),
remote: z.string().min(1),
path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const restRepositoryConfigSchema = type({
backend: "'rest'",
url: "string",
username: "string?",
password: "string?",
path: "string?",
}).and(baseRepositoryConfigSchema);
export const restRepositoryConfigSchema = z
.object({
backend: z.literal("rest"),
url: z.string().min(1),
username: z.string().optional(),
password: z.string().optional(),
path: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const sftpRepositoryConfigSchema = type({
backend: "'sftp'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
user: "string",
path: "string",
privateKey: "string",
skipHostKeyCheck: "boolean = true",
knownHosts: "string?",
}).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigSchema = z
.object({
backend: z.literal("sftp"),
host: z.string().min(1),
port: z
.union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(22),
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
.or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema)
.or(rcloneRepositoryConfigSchema)
.or(restRepositoryConfigSchema)
.or(sftpRepositoryConfigSchema);
export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [
s3RepositoryConfigSchema,
r2RepositoryConfigSchema,
localRepositoryConfigSchema,
gcsRepositoryConfigSchema,
azureRepositoryConfigSchema,
rcloneRepositoryConfigSchema,
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 = {
off: "off",
@ -132,22 +154,22 @@ export const REPOSITORY_STATUS = {
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
export const doctorStepSchema = type({
step: "string",
success: "boolean",
output: "string | null",
error: "string | null",
export const doctorStepSchema = z.object({
step: z.string(),
success: z.boolean(),
output: z.string().nullable(),
error: z.string().nullable(),
});
export type DoctorStep = typeof doctorStepSchema.infer;
export type DoctorStep = z.infer<typeof doctorStepSchema>;
export const doctorResultSchema = type({
success: "boolean",
export const doctorResultSchema = z.object({
success: z.boolean(),
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 = {
always: "always",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +1,21 @@
import { type } from "arktype";
import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi";
import { NOTIFICATION_TYPES, notificationConfigSchema } from "~/schemas/notifications";
/**
* Notification Destination Schema
*/
export const notificationDestinationSchema = type({
id: "number",
name: "string",
enabled: "boolean",
type: type.valueOf(NOTIFICATION_TYPES),
export const notificationDestinationSchema = z.object({
id: z.number(),
name: z.string(),
enabled: z.boolean(),
type: z.enum(NOTIFICATION_TYPES),
config: notificationConfigSchema,
createdAt: "number",
updatedAt: "number",
createdAt: z.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 type ListDestinationsDto = typeof listDestinationsResponse.infer;
export type ListDestinationsDto = z.infer<typeof listDestinationsResponse>;
export const listDestinationsDto = describeRoute({
description: "List all notification destinations",
@ -39,16 +33,13 @@ export const listDestinationsDto = describeRoute({
},
});
/**
* Create a new notification destination
*/
export const createDestinationBody = type({
name: "string",
export const createDestinationBody = z.object({
name: z.string(),
config: notificationConfigSchema,
});
export const createDestinationResponse = notificationDestinationSchema;
export type CreateDestinationDto = typeof createDestinationResponse.infer;
export type CreateDestinationDto = z.infer<typeof createDestinationResponse>;
export const createDestinationDto = describeRoute({
description: "Create a new notification destination",
@ -66,11 +57,8 @@ export const createDestinationDto = describeRoute({
},
});
/**
* Get a single notification destination
*/
export const getDestinationResponse = notificationDestinationSchema;
export type GetDestinationDto = typeof getDestinationResponse.infer;
export type GetDestinationDto = z.infer<typeof getDestinationResponse>;
export const getDestinationDto = describeRoute({
description: "Get a notification destination by ID",
@ -91,17 +79,14 @@ export const getDestinationDto = describeRoute({
},
});
/**
* Update a notification destination
*/
export const updateDestinationBody = type({
"name?": "string",
"enabled?": "boolean",
"config?": notificationConfigSchema,
export const updateDestinationBody = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
config: notificationConfigSchema.optional(),
});
export const updateDestinationResponse = notificationDestinationSchema;
export type UpdateDestinationDto = typeof updateDestinationResponse.infer;
export type UpdateDestinationDto = z.infer<typeof updateDestinationResponse>;
export const updateDestinationDto = describeRoute({
description: "Update a notification destination",
@ -122,13 +107,10 @@ export const updateDestinationDto = describeRoute({
},
});
/**
* Delete a notification destination
*/
export const deleteDestinationResponse = type({
message: "string",
export const deleteDestinationResponse = z.object({
message: z.string(),
});
export type DeleteDestinationDto = typeof deleteDestinationResponse.infer;
export type DeleteDestinationDto = z.infer<typeof deleteDestinationResponse>;
export const deleteDestinationDto = describeRoute({
description: "Delete a notification destination",
@ -149,13 +131,10 @@ export const deleteDestinationDto = describeRoute({
},
});
/**
* Test a notification destination
*/
export const testDestinationResponse = type({
success: "boolean",
export const testDestinationResponse = z.object({
success: z.boolean(),
});
export type TestDestinationDto = typeof testDestinationResponse.infer;
export type TestDestinationDto = z.infer<typeof testDestinationResponse>;
export const testDestinationDto = describeRoute({
description: "Test a notification destination by sending a test message",
@ -182,27 +161,21 @@ export const testDestinationDto = describeRoute({
},
});
/**
* Backup Schedule Notification Assignment Schema
*/
export const scheduleNotificationAssignmentSchema = type({
scheduleId: "number",
destinationId: "number",
notifyOnStart: "boolean",
notifyOnSuccess: "boolean",
notifyOnWarning: "boolean",
notifyOnFailure: "boolean",
createdAt: "number",
export const scheduleNotificationAssignmentSchema = z.object({
scheduleId: z.number(),
destinationId: z.number(),
notifyOnStart: z.boolean(),
notifyOnSuccess: z.boolean(),
notifyOnWarning: z.boolean(),
notifyOnFailure: z.boolean(),
createdAt: z.number(),
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 type GetScheduleNotificationsDto = typeof getScheduleNotificationsResponse.infer;
export type GetScheduleNotificationsDto = z.infer<typeof getScheduleNotificationsResponse>;
export const getScheduleNotificationsDto = describeRoute({
description: "Get notification assignments for a backup schedule",
@ -220,21 +193,20 @@ export const getScheduleNotificationsDto = describeRoute({
},
});
/**
* Update notifications for a backup schedule
*/
export const updateScheduleNotificationsBody = type({
assignments: type({
destinationId: "number",
notifyOnStart: "boolean",
notifyOnSuccess: "boolean",
notifyOnWarning: "boolean",
notifyOnFailure: "boolean",
}).array(),
export const updateScheduleNotificationsBody = z.object({
assignments: z
.object({
destinationId: z.number(),
notifyOnStart: z.boolean(),
notifyOnSuccess: z.boolean(),
notifyOnWarning: z.boolean(),
notifyOnFailure: z.boolean(),
})
.array(),
});
export const updateScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
export type UpdateScheduleNotificationsDto = typeof updateScheduleNotificationsResponse.infer;
export type UpdateScheduleNotificationsDto = z.infer<typeof updateScheduleNotificationsResponse>;
export const updateScheduleNotificationsDto = describeRoute({
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 type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto";
import { toMessage } from "../../utils/errors";
import { type } from "arktype";
import { getOrganizationId } from "~/server/core/request-context";
import { formatBytes } from "~/utils/format-bytes";
@ -192,10 +191,11 @@ const updateDestination = async (
updateData.enabled = updates.enabled;
}
const newConfig = notificationConfigSchema(updates.config || existing.config);
if (newConfig instanceof type.errors) {
const newConfigResult = notificationConfigSchema.safeParse(updates.config || existing.config);
if (!newConfigResult.success) {
throw new BadRequestError("Invalid notification configuration");
}
const newConfig = newConfigResult.data;
const encryptedConfig = await encryptSensitiveFields(newConfig);
updateData.config = encryptedConfig;

View file

@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
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 { restic } from "~/server/utils/restic";
import { toMessage } from "~/server/utils/errors";
@ -61,21 +61,24 @@ const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal)
};
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);
if (parsedJson === null) {
return null;
}
const parsed = schema(parsedJson);
const parsed = schema.safeParse(parsedJson);
if (parsed instanceof type.errors) {
logger.error(`Invalid check output format: ${parsed.summary}`);
if (!parsed.success) {
logger.error(`Invalid check output format: ${parsed.error.message}`);
return null;
}
return parsed;
return parsed.data;
};
const checkAbortSignal = (signal: AbortSignal | undefined): void => {

View file

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

View file

@ -1,6 +1,5 @@
import crypto from "node:crypto";
import nodePath from "node:path";
import { type } from "arktype";
import { and, eq } from "drizzle-orm";
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import {
@ -732,10 +731,11 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
throw new NotFoundError("Repository not found");
}
const existingConfig = repositoryConfigSchema(existing.config);
if (existingConfig instanceof type.errors) {
const existingConfigResult = repositoryConfigSchema.safeParse(existing.config);
if (!existingConfigResult.success) {
throw new InternalServerError("Invalid repository configuration");
}
const existingConfig = existingConfigResult.data;
let newName = existing.name;
if (updates.name) {
@ -747,10 +747,11 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
let parsedConfig = existingConfig;
if (updates.config) {
const nextConfig = repositoryConfigSchema(updates.config);
if (nextConfig instanceof type.errors) {
const nextConfigResult = repositoryConfigSchema.safeParse(updates.config);
if (!nextConfigResult.success) {
throw new BadRequestError("Invalid repository configuration");
}
const nextConfig = nextConfigResult.data;
if (nextConfig.backend !== existing.type) {
throw new BadRequestError("Repository backend cannot be changed");
@ -759,7 +760,7 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
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 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";
export const publicSsoProvidersDto = type({
providers: type({
providerId: "string",
organizationSlug: "string",
})
.onUndeclaredKey("delete")
export const publicSsoProvidersDto = z.object({
providers: z
.object({
providerId: z.string(),
organizationSlug: z.string(),
})
.array(),
});
export type PublicSsoProvidersDto = typeof publicSsoProvidersDto.infer;
export type PublicSsoProvidersDto = z.infer<typeof publicSsoProvidersDto>;
export const getPublicSsoProvidersDto = describeRoute({
description: "Get public SSO providers for the instance",
@ -28,25 +28,29 @@ export const getPublicSsoProvidersDto = describeRoute({
},
});
export const ssoSettingsResponse = type({
providers: type({
providerId: "string",
type: "string",
issuer: "string",
domain: "string",
autoLinkMatchingEmails: "boolean",
organizationId: "string | null",
}).array(),
invitations: type({
id: "string",
email: "string",
role: "string",
status: "string",
expiresAt: "string",
}).array(),
export const ssoSettingsResponse = z.object({
providers: z
.object({
providerId: z.string(),
type: z.string(),
issuer: z.string(),
domain: z.string(),
autoLinkMatchingEmails: z.boolean(),
organizationId: z.string().nullable(),
})
.array(),
invitations: z
.object({
id: z.string(),
email: z.string(),
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({
description: "Get SSO providers and invitations for the active organization",
@ -95,8 +99,8 @@ export const deleteSsoInvitationDto = describeRoute({
},
});
export const updateSsoProviderAutoLinkingBody = type({
enabled: "boolean",
export const updateSsoProviderAutoLinkingBody = z.object({
enabled: z.boolean(),
});
export const updateSsoProviderAutoLinkingDto = describeRoute({

View file

@ -1,32 +1,32 @@
import { type } from "arktype";
import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi";
export const capabilitiesSchema = type({
rclone: "boolean",
sysAdmin: "boolean",
export const capabilitiesSchema = z.object({
rclone: z.boolean(),
sysAdmin: z.boolean(),
});
export const systemInfoResponse = type({
export const systemInfoResponse = z.object({
capabilities: capabilitiesSchema,
});
export type SystemInfoDto = typeof systemInfoResponse.infer;
export type SystemInfoDto = z.infer<typeof systemInfoResponse>;
export const releaseInfoSchema = type({
version: "string",
url: "string",
publishedAt: "string",
body: "string",
export const releaseInfoSchema = z.object({
version: z.string(),
url: z.string(),
publishedAt: z.string(),
body: z.string(),
});
export const updateInfoResponse = type({
currentVersion: "string",
latestVersion: "string",
hasUpdate: "boolean",
export const updateInfoResponse = z.object({
currentVersion: z.string(),
latestVersion: z.string(),
hasUpdate: z.boolean(),
missedReleases: releaseInfoSchema.array(),
});
export type UpdateInfoDto = typeof updateInfoResponse.infer;
export type UpdateInfoDto = z.infer<typeof updateInfoResponse>;
export const systemInfoDto = describeRoute({
description: "Get system information including available capabilities",
@ -60,8 +60,8 @@ export const getUpdatesDto = describeRoute({
},
});
export const downloadResticPasswordBodySchema = type({
password: "string",
export const downloadResticPasswordBodySchema = z.object({
password: z.string(),
});
export const downloadResticPasswordDto = describeRoute({
@ -81,14 +81,14 @@ export const downloadResticPasswordDto = describeRoute({
},
});
export const registrationStatusResponse = type({
enabled: "boolean",
export const registrationStatusResponse = z.object({
enabled: z.boolean(),
});
export type RegistrationStatusDto = typeof registrationStatusResponse.infer;
export type RegistrationStatusDto = z.infer<typeof registrationStatusResponse>;
export const registrationStatusBody = type({
enabled: "boolean",
export const registrationStatusBody = z.object({
enabled: z.boolean(),
});
export const getRegistrationStatusDto = describeRoute({
@ -123,11 +123,11 @@ export const setRegistrationStatusDto = describeRoute({
},
});
export const devPanelResponse = type({
enabled: "boolean",
export const devPanelResponse = z.object({
enabled: z.boolean(),
});
export type DevPanelDto = typeof devPanelResponse.infer;
export type DevPanelDto = z.infer<typeof devPanelResponse>;
export const getDevPanelDto = describeRoute({
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 { path, ...query } = c.req.valid("query");
const offset = Math.max(0, Number.parseInt(query.offset ?? "0", 10) || 0);
const limit = Math.min(1000, Math.max(1, Number.parseInt(query.limit ?? "500", 10) || 500));
const offset = Math.max(0, query.offset ?? 0);
const limit = Math.min(1000, Math.max(1, query.limit ?? 500));
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 { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes";
export const volumeSchema = type({
id: "number",
shortId: "string",
name: "string",
type: type.valueOf(BACKEND_TYPES),
status: type.valueOf(BACKEND_STATUS),
lastError: "string | null",
createdAt: "number",
updatedAt: "number",
lastHealthCheck: "number",
export const volumeSchema = z.object({
id: z.number(),
shortId: z.string(),
name: z.string(),
type: z.enum(BACKEND_TYPES),
status: z.enum(BACKEND_STATUS),
lastError: z.string().nullable(),
createdAt: z.number(),
updatedAt: z.number(),
lastHealthCheck: z.number(),
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 type ListVolumesDto = typeof listVolumesResponse.infer;
export type ListVolumesDto = z.infer<typeof listVolumesResponse>;
export const listVolumesDto = describeRoute({
description: "List all volumes",
@ -40,16 +37,13 @@ export const listVolumesDto = describeRoute({
},
});
/**
* Create a new volume
*/
export const createVolumeBody = type({
name: "string",
export const createVolumeBody = z.object({
name: z.string(),
config: volumeConfigSchema,
});
export const createVolumeResponse = volumeSchema;
export type CreateVolumeDto = typeof createVolumeResponse.infer;
export type CreateVolumeDto = z.infer<typeof createVolumeResponse>;
export const createVolumeDto = describeRoute({
description: "Create a new volume",
@ -67,13 +61,10 @@ export const createVolumeDto = describeRoute({
},
});
/**
* Delete a volume
*/
export const deleteVolumeResponse = type({
message: "string",
export const deleteVolumeResponse = z.object({
message: z.string(),
});
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer;
export type DeleteVolumeDto = z.infer<typeof deleteVolumeResponse>;
export const deleteVolumeDto = describeRoute({
description: "Delete a volume",
@ -91,21 +82,19 @@ export const deleteVolumeDto = describeRoute({
},
});
const statfsSchema = type({
total: "number",
used: "number",
free: "number",
const statfsSchema = z.object({
total: z.number(),
used: z.number(),
free: z.number(),
});
const getVolumeResponse = type({
const getVolumeResponse = z.object({
volume: volumeSchema,
statfs: statfsSchema,
});
export type GetVolumeDto = typeof getVolumeResponse.infer;
/**
* Get a volume
*/
export type GetVolumeDto = z.infer<typeof getVolumeResponse>;
export const getVolumeDto = describeRoute({
description: "Get a volume by name",
operationId: "getVolume",
@ -125,19 +114,16 @@ export const getVolumeDto = describeRoute({
},
});
/**
* Update a volume
*/
export const updateVolumeBody = type({
name: "string?",
autoRemount: "boolean?",
export const updateVolumeBody = z.object({
name: z.string().optional(),
autoRemount: z.boolean().optional(),
config: volumeConfigSchema.optional(),
});
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
export type UpdateVolumeBody = z.infer<typeof updateVolumeBody>;
export const updateVolumeResponse = volumeSchema;
export type UpdateVolumeDto = typeof updateVolumeResponse.infer;
export type UpdateVolumeDto = z.infer<typeof updateVolumeResponse>;
export const updateVolumeDto = describeRoute({
description: "Update a volume's configuration",
@ -158,18 +144,15 @@ export const updateVolumeDto = describeRoute({
},
});
/**
* Test connection
*/
export const testConnectionBody = type({
export const testConnectionBody = z.object({
config: volumeConfigSchema,
});
export const testConnectionResponse = type({
success: "boolean",
message: "string",
export const testConnectionResponse = z.object({
success: z.boolean(),
message: z.string(),
});
export type TestConnectionDto = typeof testConnectionResponse.infer;
export type TestConnectionDto = z.infer<typeof testConnectionResponse>;
export const testConnectionDto = describeRoute({
description: "Test connection to backend",
@ -187,14 +170,11 @@ export const testConnectionDto = describeRoute({
},
});
/**
* Mount volume
*/
export const mountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
export const mountVolumeResponse = z.object({
error: z.string().optional(),
status: z.enum(BACKEND_STATUS),
});
export type MountVolumeDto = typeof mountVolumeResponse.infer;
export type MountVolumeDto = z.infer<typeof mountVolumeResponse>;
export const mountVolumeDto = describeRoute({
description: "Mount a volume",
@ -212,14 +192,11 @@ export const mountVolumeDto = describeRoute({
},
});
/**
* Unmount volume
*/
export const unmountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
export const unmountVolumeResponse = z.object({
error: z.string().optional(),
status: z.enum(BACKEND_STATUS),
});
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer;
export type UnmountVolumeDto = z.infer<typeof unmountVolumeResponse>;
export const unmountVolumeDto = describeRoute({
description: "Unmount a volume",
@ -237,11 +214,11 @@ export const unmountVolumeDto = describeRoute({
},
});
export const healthCheckResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
export const healthCheckResponse = z.object({
error: z.string().optional(),
status: z.enum(BACKEND_STATUS),
});
export type HealthCheckDto = typeof healthCheckResponse.infer;
export type HealthCheckDto = z.infer<typeof healthCheckResponse>;
export const healthCheckDto = describeRoute({
description: "Perform a health check on a volume",
@ -262,31 +239,28 @@ export const healthCheckDto = describeRoute({
},
});
/**
* List files in a volume
*/
const fileEntrySchema = type({
name: "string",
path: "string",
type: type.enumerated("file", "directory"),
size: "number?",
modifiedAt: "number?",
const fileEntrySchema = z.object({
name: z.string(),
path: z.string(),
type: z.enum(["file", "directory"]),
size: z.number().optional(),
modifiedAt: z.number().optional(),
});
export const listFilesResponse = type({
export const listFilesResponse = z.object({
files: fileEntrySchema.array(),
path: "string",
offset: "number",
limit: "number",
total: "number",
hasMore: "boolean",
path: z.string(),
offset: z.number(),
limit: z.number(),
total: z.number(),
hasMore: z.boolean(),
});
export type ListFilesDto = typeof listFilesResponse.infer;
export type ListFilesDto = z.infer<typeof listFilesResponse>;
export const listFilesQuery = type({
path: "string?",
offset: "string.integer?",
limit: "string.integer?",
export const listFilesQuery = z.object({
path: z.string().optional(),
offset: z.coerce.number().int().optional(),
limit: z.coerce.number().int().optional(),
});
export const listFilesDto = describeRoute({
@ -305,14 +279,11 @@ export const listFilesDto = describeRoute({
},
});
/**
* Browse filesystem directories
*/
export const browseFilesystemResponse = type({
export const browseFilesystemResponse = z.object({
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({
description: "Browse directories on the host filesystem",

View file

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

View file

@ -1,7 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { type } from "arktype";
import { throttle } from "es-toolkit";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import {
@ -108,11 +107,11 @@ export const backup = async (
if (options.onProgress) {
try {
const jsonData = JSON.parse(data);
const progress = resticBackupProgressSchema(jsonData);
if (!(progress instanceof type.errors)) {
options.onProgress(progress);
const progressResult = resticBackupProgressSchema.safeParse(jsonData);
if (progressResult.success) {
options.onProgress(progressResult.data);
} else {
logger.error(progress.summary);
logger.error(progressResult.error.message);
}
} catch {
// 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)}`);
const result = resticBackupOutputSchema(summaryLine);
const result = resticBackupOutputSchema.safeParse(summaryLine);
if (result instanceof type.errors) {
logger.error(`Restic backup output validation failed: ${result.summary}`);
if (!result.success) {
logger.error(`Restic backup output validation failed: ${result.error.message}`);
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 { ResticError } from "~/server/utils/errors";
import { logger } from "~/server/utils/logger";
@ -8,40 +8,54 @@ import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
const lsNodeSchema = type({
name: "string",
type: "string",
path: "string",
uid: "number?",
gid: "number?",
size: "number?",
mode: "number?",
mtime: "string?",
atime: "string?",
ctime: "string?",
struct_type: "'node'",
const lsNodeSchema = z.object({
name: z.string(),
type: z.string(),
path: z.string(),
uid: z.number().optional(),
gid: z.number().optional(),
size: z.number().optional(),
mode: z.number().optional(),
mtime: z.string().optional(),
atime: z.string().optional(),
ctime: z.string().optional(),
struct_type: z.literal("node"),
});
const lsSnapshotInfoSchema = type({
time: "string",
parent: "string?",
tree: "string",
paths: "string[]",
hostname: "string",
username: "string?",
id: "string",
short_id: "string",
struct_type: "'snapshot'",
message_type: "'snapshot'",
const lsSnapshotInfoSchema = z.object({
time: z.string(),
parent: z.string().optional(),
tree: z.string(),
paths: z.array(z.string()),
hostname: z.string(),
username: z.string().optional(),
id: z.string(),
short_id: z.string(),
struct_type: z.literal("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 (
config: RepositoryConfig,
snapshotId: string,
organizationId: string,
path?: string,
options?: { offset?: number; limit?: number },
) => {
): Promise<ResticLsResult> => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, organizationId);
@ -53,8 +67,8 @@ export const ls = async (
addCommonArgs(args, env, config);
let snapshot: typeof lsSnapshotInfoSchema.infer | null = null;
const nodes: Array<typeof lsNodeSchema.infer> = [];
let snapshot: LsSnapshotInfo | null = null;
const nodes: LsNode[] = [];
let totalNodes = 0;
let isFirstLine = true;
let hasMore = false;
@ -77,21 +91,21 @@ export const ls = async (
if (isFirstLine) {
isFirstLine = false;
const snapshotValidation = lsSnapshotInfoSchema(data);
if (!(snapshotValidation instanceof type.errors)) {
snapshot = snapshotValidation;
const snapshotValidation = lsSnapshotInfoSchema.safeParse(data);
if (snapshotValidation.success) {
snapshot = snapshotValidation.data;
}
return;
}
const nodeValidation = lsNodeSchema(data);
if (nodeValidation instanceof type.errors) {
logger.warn(`Skipping invalid node: ${nodeValidation.summary}`);
const nodeValidation = lsNodeSchema.safeParse(data);
if (!nodeValidation.success) {
logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`);
return;
}
if (totalNodes >= offset && totalNodes < offset + limit) {
nodes.push(nodeValidation);
nodes.push(nodeValidation.data);
}
totalNodes++;
@ -116,7 +130,7 @@ export const ls = async (
}
return {
snapshot: snapshot as typeof lsSnapshotInfoSchema.infer | null,
snapshot,
nodes,
pagination: {
offset,

View file

@ -1,5 +1,5 @@
import path from "node:path";
import { type } from "arktype";
import { z } from "zod";
import { throttle } from "es-toolkit";
import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic";
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 { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
const restoreProgressSchema = type({
message_type: "'status' | 'summary'",
seconds_elapsed: "number",
percent_done: "number = 0",
total_files: "number",
files_restored: "number = 0",
total_bytes: "number = 0",
bytes_restored: "number = 0",
const restoreProgressSchema = z.object({
message_type: z.enum(["status", "summary"]),
seconds_elapsed: z.number(),
percent_done: z.number().default(0),
total_files: z.number(),
files_restored: z.number().default(0),
total_bytes: z.number().default(0),
bytes_restored: z.number().default(0),
});
export type RestoreProgress = typeof restoreProgressSchema.infer;
export type RestoreProgress = z.infer<typeof restoreProgressSchema>;
export const restore = async (
config: RepositoryConfig,
@ -100,11 +100,11 @@ export const restore = async (
if (options.onProgress) {
try {
const jsonData = JSON.parse(data);
const progress = restoreProgressSchema(jsonData);
if (!(progress instanceof type.errors)) {
options.onProgress(progress);
const progress = restoreProgressSchema.safeParse(jsonData);
if (progress.success) {
options.onProgress(progress.data);
} else {
logger.error(progress.summary);
logger.error(progress.error.message);
}
} catch {
// 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)}`);
const result = resticRestoreOutputSchema(summaryLine);
const result = resticRestoreOutputSchema.safeParse(summaryLine);
if (result instanceof type.errors) {
logger.warn(`Restic restore output validation failed: ${result.summary}`);
if (!result.success) {
logger.warn(`Restic restore output validation failed: ${result.error.message}`);
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
const fallback: ResticRestoreOutputDto = {
message_type: "summary" as const,
@ -159,8 +159,8 @@ export const restore = async (
}
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 { resticSnapshotSummarySchema } from "~/schemas/restic-dto";
import { logger } from "~/server/utils/logger";
@ -8,18 +8,18 @@ import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
const snapshotInfoSchema = type({
gid: "number?",
hostname: "string",
id: "string",
parent: "string?",
paths: "string[]",
program_version: "string?",
short_id: "string",
time: "string",
uid: "number?",
username: "string?",
tags: "string[]?",
const snapshotInfoSchema = z.object({
gid: z.number().optional(),
hostname: z.string(),
id: z.string(),
parent: z.string().optional(),
paths: z.array(z.string()),
program_version: z.string().optional(),
short_id: z.string(),
time: z.string(),
uid: z.number().optional(),
username: z.string().optional(),
tags: z.array(z.string()).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}`);
}
const result = snapshotInfoSchema.array()(JSON.parse(res.stdout));
const result = snapshotInfoSchema.array().safeParse(JSON.parse(res.stdout));
if (result instanceof type.errors) {
logger.error(`Restic snapshots output validation failed: ${result.summary}`);
throw new Error(`Restic snapshots output validation failed: ${result.summary}`);
if (!result.success) {
logger.error(`Restic snapshots output validation failed: ${result.error.message}`);
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 { resticStatsSchema } from "~/schemas/restic-dto";
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 result = resticStatsSchema(parsedJson);
const result = resticStatsSchema.safeParse(parsedJson);
if (result instanceof type.errors) {
logger.error(`Restic stats output validation failed: ${result.summary}`);
throw new Error(`Restic stats output validation failed: ${result.summary}`);
if (!result.success) {
logger.error(`Restic stats output validation failed: ${result.error.message}`);
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-ssr-query": "^1.166.2",
"@tanstack/react-start": "^1.166.2",
"arktype": "^2.2.0",
"better-auth": "^1.5.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -69,6 +68,7 @@
"tiny-typed-emitter": "^2.1.0",
"web-haptics": "^0.0.6",
"yaml": "^2.8.2",
"zod": "^4.0.1",
},
"devDependencies": {
"@babel/preset-typescript": "^7.28.5",

View file

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