parent
dd8f471d60
commit
df312cd419
58 changed files with 3776 additions and 3795 deletions
|
|
@ -50,6 +50,7 @@
|
|||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)`),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -41,5 +41,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
|||
case "sftp": {
|
||||
return makeSftpBackend(volume.config, path);
|
||||
}
|
||||
default: {
|
||||
throw new Error("Unsupported backend");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue