refactor: implement shape stripping for notification, repository, and volume configurations

This commit is contained in:
Jakub Trávník 2025-12-17 14:51:31 +01:00
parent 1ea5c042f5
commit aa957711e5
15 changed files with 251 additions and 76 deletions

View file

@ -17,7 +17,8 @@ import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input"; import { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchema } from "~/schemas/notifications"; import { NOTIFICATION_CONFIG_SHAPES, notificationConfigSchema, type NotificationConfig } from "~/schemas/notifications";
import { stripDiscriminatedUnion } from "~/utils/object";
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
@ -26,6 +27,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type NotificationFormValues = typeof formSchema.inferIn; export type NotificationFormValues = typeof formSchema.inferIn;
export const toNotificationConfig = (values: NotificationFormValues): NotificationConfig =>
stripDiscriminatedUnion(values, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
type Props = { type Props = {
onSubmit: (values: NotificationFormValues) => void; onSubmit: (values: NotificationFormValues) => void;
mode?: "create" | "update"; mode?: "create" | "update";

View file

@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-notification"; import type { Route } from "./+types/create-notification";
import { Alert, AlertDescription } from "~/client/components/ui/alert"; import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form"; import { CreateNotificationForm, type NotificationFormValues, toNotificationConfig } from "../components/create-notification-form";
export const handle = { export const handle = {
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }], breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
@ -38,7 +38,7 @@ export default function CreateNotification() {
}); });
const handleSubmit = (values: NotificationFormValues) => { const handleSubmit = (values: NotificationFormValues) => {
createNotification.mutate({ body: { name: values.name, config: values } }); createNotification.mutate({ body: { name: values.name, config: toNotificationConfig(values) } });
}; };
return ( return (

View file

@ -26,7 +26,7 @@ import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, Save, TestTube2, Trash2, X } from "lucide-react"; import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
import { Alert, AlertDescription } from "~/client/components/ui/alert"; import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form"; import { CreateNotificationForm, type NotificationFormValues, toNotificationConfig } from "../components/create-notification-form";
export const handle = { export const handle = {
breadcrumb: (match: Route.MetaArgs) => [ breadcrumb: (match: Route.MetaArgs) => [
@ -109,7 +109,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
path: { id: String(data.id) }, path: { id: String(data.id) },
body: { body: {
name: values.name, name: values.name,
config: values, config: toNotificationConfig(values),
}, },
}); });
}; };
@ -172,7 +172,12 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} /> <CreateNotificationForm
mode="update"
formId={formId}
onSubmit={handleSubmit}
initialValues={{ name: data.name, ...data.config }}
/>
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}> <Button type="submit" form={formId} loading={updateDestination.isPending}>
<Save className="h-4 w-4 mr-2" /> <Save className="h-4 w-4 mr-2" />

View file

@ -20,8 +20,9 @@ import { SecretInput } from "../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic"; import { COMPRESSION_MODES, REPOSITORY_CONFIG_SHAPES, repositoryConfigSchema, type RepositoryConfig } from "~/schemas/restic";
import { Checkbox } from "../../../components/ui/checkbox"; import { Checkbox } from "../../../components/ui/checkbox";
import { stripDiscriminatedUnion } from "~/utils/object";
import { import {
LocalRepositoryForm, LocalRepositoryForm,
S3RepositoryForm, S3RepositoryForm,
@ -41,6 +42,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn; export type RepositoryFormValues = typeof formSchema.inferIn;
export const toRepositoryConfig = (values: RepositoryFormValues): RepositoryConfig =>
stripDiscriminatedUnion(values, "backend", REPOSITORY_CONFIG_SHAPES) as unknown as RepositoryConfig;
type Props = { type Props = {
onSubmit: (values: RepositoryFormValues) => void; onSubmit: (values: RepositoryFormValues) => void;
mode?: "create" | "update"; mode?: "create" | "update";

View file

@ -7,6 +7,7 @@ import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-qu
import { import {
CreateRepositoryForm, CreateRepositoryForm,
type RepositoryFormValues, type RepositoryFormValues,
toRepositoryConfig,
} from "~/client/modules/repositories/components/create-repository-form"; } from "~/client/modules/repositories/components/create-repository-form";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
@ -43,7 +44,7 @@ export default function CreateRepository() {
const handleSubmit = (values: RepositoryFormValues) => { const handleSubmit = (values: RepositoryFormValues) => {
createRepository.mutate({ createRepository.mutate({
body: { body: {
config: values, config: toRepositoryConfig(values),
name: values.name, name: values.name,
compressionMode: values.compressionMode, compressionMode: values.compressionMode,
}, },

View file

@ -23,6 +23,8 @@ import { testConnectionMutation } from "../../../api-client/@tanstack/react-quer
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms"; import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms";
import { VOLUME_CONFIG_SHAPES, type BackendConfig } from "~/schemas/volumes";
import { stripDiscriminatedUnion } from "~/utils/object";
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
@ -31,6 +33,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn; export type FormValues = typeof formSchema.inferIn;
export const toBackendConfig = (values: FormValues): BackendConfig =>
stripDiscriminatedUnion(values, "backend", VOLUME_CONFIG_SHAPES) as unknown as BackendConfig;
type Props = { type Props = {
onSubmit: (values: FormValues) => void; onSubmit: (values: FormValues) => void;
mode?: "create" | "update"; mode?: "create" | "update";
@ -98,7 +103,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") { if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
testBackendConnection.mutate({ testBackendConnection.mutate({
body: { config: formValues }, body: { config: toBackendConfig(formValues) },
}); });
} }
}; };

View file

@ -4,7 +4,7 @@ import { useId } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { CreateVolumeForm, type FormValues, toBackendConfig } from "~/client/modules/volumes/components/create-volume-form";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
@ -40,7 +40,7 @@ export default function CreateVolume() {
const handleSubmit = (values: FormValues) => { const handleSubmit = (values: FormValues) => {
createVolume.mutate({ createVolume.mutate({
body: { body: {
config: values, config: toBackendConfig(values),
name: values.name, name: values.name,
}, },
}); });

View file

@ -3,7 +3,7 @@ import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { CreateVolumeForm, type FormValues, toBackendConfig } from "~/client/modules/volumes/components/create-volume-form";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -59,7 +59,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
if (pendingValues) { if (pendingValues) {
updateMutation.mutate({ updateMutation.mutate({
path: { name: volume.name }, path: { name: volume.name },
body: { name: pendingValues.name, config: pendingValues }, body: { name: pendingValues.name, config: toBackendConfig(pendingValues) },
}); });
} }
}; };
@ -69,7 +69,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
<Card className="p-6"> <Card className="p-6">
<CreateVolumeForm <CreateVolumeForm
initialValues={{ ...volume, ...volume.config }} initialValues={{ name: volume.name, ...volume.config }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
mode="update" mode="update"
loading={updateMutation.isPending} loading={updateMutation.isPending}

View file

@ -13,7 +13,7 @@ export const NOTIFICATION_TYPES = {
export type NotificationType = keyof typeof NOTIFICATION_TYPES; export type NotificationType = keyof typeof NOTIFICATION_TYPES;
export const emailNotificationConfigSchema = type({ export const emailNotificationConfigShape = {
type: "'email'", type: "'email'",
smtpHost: "string", smtpHost: "string",
smtpPort: "1 <= number <= 65535", smtpPort: "1 <= number <= 65535",
@ -22,59 +22,75 @@ export const emailNotificationConfigSchema = type({
from: "string", from: "string",
to: "string[]", to: "string[]",
useTLS: "boolean", useTLS: "boolean",
}); } as const;
export const slackNotificationConfigSchema = type({ export const emailNotificationConfigSchema = type(emailNotificationConfigShape);
export const slackNotificationConfigShape = {
type: "'slack'", type: "'slack'",
webhookUrl: "string", webhookUrl: "string",
channel: "string?", channel: "string?",
username: "string?", username: "string?",
iconEmoji: "string?", iconEmoji: "string?",
}); } as const;
export const discordNotificationConfigSchema = type({ export const slackNotificationConfigSchema = type(slackNotificationConfigShape);
export const discordNotificationConfigShape = {
type: "'discord'", type: "'discord'",
webhookUrl: "string", webhookUrl: "string",
username: "string?", username: "string?",
avatarUrl: "string?", avatarUrl: "string?",
threadId: "string?", threadId: "string?",
}); } as const;
export const gotifyNotificationConfigSchema = type({ export const discordNotificationConfigSchema = type(discordNotificationConfigShape);
export const gotifyNotificationConfigShape = {
type: "'gotify'", type: "'gotify'",
serverUrl: "string", serverUrl: "string",
token: "string", token: "string",
path: "string?", path: "string?",
priority: "0 <= number <= 10", priority: "0 <= number <= 10",
}); } as const;
export const ntfyNotificationConfigSchema = type({ export const gotifyNotificationConfigSchema = type(gotifyNotificationConfigShape);
export const ntfyNotificationConfigShape = {
type: "'ntfy'", type: "'ntfy'",
serverUrl: "string?", serverUrl: "string?",
topic: "string", topic: "string",
priority: "'max' | 'high' | 'default' | 'low' | 'min'", priority: "'max' | 'high' | 'default' | 'low' | 'min'",
username: "string?", username: "string?",
password: "string?", password: "string?",
}); } as const;
export const pushoverNotificationConfigSchema = type({ export const ntfyNotificationConfigSchema = type(ntfyNotificationConfigShape);
export const pushoverNotificationConfigShape = {
type: "'pushover'", type: "'pushover'",
userKey: "string", userKey: "string",
apiToken: "string", apiToken: "string",
devices: "string?", devices: "string?",
priority: "-1 | 0 | 1", priority: "-1 | 0 | 1",
}); } as const;
export const telegramNotificationConfigSchema = type({ export const pushoverNotificationConfigSchema = type(pushoverNotificationConfigShape);
export const telegramNotificationConfigShape = {
type: "'telegram'", type: "'telegram'",
botToken: "string", botToken: "string",
chatId: "string", chatId: "string",
}); } as const;
export const customNotificationConfigSchema = type({ export const telegramNotificationConfigSchema = type(telegramNotificationConfigShape);
export const customNotificationConfigShape = {
type: "'custom'", type: "'custom'",
shoutrrrUrl: "string", shoutrrrUrl: "string",
}); } as const;
export const customNotificationConfigSchema = type(customNotificationConfigShape);
export const notificationConfigSchema = emailNotificationConfigSchema export const notificationConfigSchema = emailNotificationConfigSchema
.or(slackNotificationConfigSchema) .or(slackNotificationConfigSchema)
@ -87,6 +103,17 @@ export const notificationConfigSchema = emailNotificationConfigSchema
export type NotificationConfig = typeof notificationConfigSchema.infer; export type NotificationConfig = typeof notificationConfigSchema.infer;
export const NOTIFICATION_CONFIG_SHAPES = {
email: emailNotificationConfigShape,
slack: slackNotificationConfigShape,
discord: discordNotificationConfigShape,
gotify: gotifyNotificationConfigShape,
ntfy: ntfyNotificationConfigShape,
pushover: pushoverNotificationConfigShape,
telegram: telegramNotificationConfigShape,
custom: customNotificationConfigShape,
} as const;
export const NOTIFICATION_EVENTS = { export const NOTIFICATION_EVENTS = {
start: "start", start: "start",
success: "success", success: "success",

View file

@ -14,70 +14,88 @@ export const REPOSITORY_BACKENDS = {
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS; export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
// Common fields for all repository configs // Common fields for all repository configs
const baseRepositoryConfigSchema = type({ export const baseRepositoryConfigShape = {
isExistingRepository: "boolean?", isExistingRepository: "boolean?",
customPassword: "string?", customPassword: "string?",
}); } as const;
export const s3RepositoryConfigSchema = type({ const baseRepositoryConfigSchema = type(baseRepositoryConfigShape);
export const s3RepositoryConfigShape = {
backend: "'s3'", backend: "'s3'",
endpoint: "string", endpoint: "string",
bucket: "string", bucket: "string",
accessKeyId: "string", accessKeyId: "string",
secretAccessKey: "string", secretAccessKey: "string",
}).and(baseRepositoryConfigSchema); } as const;
export const r2RepositoryConfigSchema = type({ export const s3RepositoryConfigSchema = type(s3RepositoryConfigShape).and(baseRepositoryConfigSchema);
export const r2RepositoryConfigShape = {
backend: "'r2'", backend: "'r2'",
endpoint: "string", endpoint: "string",
bucket: "string", bucket: "string",
accessKeyId: "string", accessKeyId: "string",
secretAccessKey: "string", secretAccessKey: "string",
}).and(baseRepositoryConfigSchema); } as const;
export const localRepositoryConfigSchema = type({ export const r2RepositoryConfigSchema = type(r2RepositoryConfigShape).and(baseRepositoryConfigSchema);
export const localRepositoryConfigShape = {
backend: "'local'", backend: "'local'",
name: "string", name: "string",
path: "string?", path: "string?",
}).and(baseRepositoryConfigSchema); } as const;
export const gcsRepositoryConfigSchema = type({ export const localRepositoryConfigSchema = type(localRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigShape = {
backend: "'gcs'", backend: "'gcs'",
bucket: "string", bucket: "string",
projectId: "string", projectId: "string",
credentialsJson: "string", credentialsJson: "string",
}).and(baseRepositoryConfigSchema); } as const;
export const azureRepositoryConfigSchema = type({ export const gcsRepositoryConfigSchema = type(gcsRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const azureRepositoryConfigShape = {
backend: "'azure'", backend: "'azure'",
container: "string", container: "string",
accountName: "string", accountName: "string",
accountKey: "string", accountKey: "string",
endpointSuffix: "string?", endpointSuffix: "string?",
}).and(baseRepositoryConfigSchema); } as const;
export const rcloneRepositoryConfigSchema = type({ export const azureRepositoryConfigSchema = type(azureRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const rcloneRepositoryConfigShape = {
backend: "'rclone'", backend: "'rclone'",
remote: "string", remote: "string",
path: "string", path: "string",
}).and(baseRepositoryConfigSchema); } as const;
export const restRepositoryConfigSchema = type({ export const rcloneRepositoryConfigSchema = type(rcloneRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const restRepositoryConfigShape = {
backend: "'rest'", backend: "'rest'",
url: "string", url: "string",
username: "string?", username: "string?",
password: "string?", password: "string?",
path: "string?", path: "string?",
}).and(baseRepositoryConfigSchema); } as const;
export const sftpRepositoryConfigSchema = type({ export const restRepositoryConfigSchema = type(restRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigShape = {
backend: "'sftp'", backend: "'sftp'",
host: "string", host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22), port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
user: "string", user: "string",
path: "string", path: "string",
privateKey: "string", privateKey: "string",
}).and(baseRepositoryConfigSchema); } as const;
export const sftpRepositoryConfigSchema = type(sftpRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const repositoryConfigSchema = s3RepositoryConfigSchema export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema) .or(r2RepositoryConfigSchema)
@ -90,6 +108,17 @@ export const repositoryConfigSchema = s3RepositoryConfigSchema
export type RepositoryConfig = typeof repositoryConfigSchema.infer; export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export const REPOSITORY_CONFIG_SHAPES = {
local: { ...baseRepositoryConfigShape, ...localRepositoryConfigShape },
s3: { ...baseRepositoryConfigShape, ...s3RepositoryConfigShape },
r2: { ...baseRepositoryConfigShape, ...r2RepositoryConfigShape },
gcs: { ...baseRepositoryConfigShape, ...gcsRepositoryConfigShape },
azure: { ...baseRepositoryConfigShape, ...azureRepositoryConfigShape },
rclone: { ...baseRepositoryConfigShape, ...rcloneRepositoryConfigShape },
rest: { ...baseRepositoryConfigShape, ...restRepositoryConfigShape },
sftp: { ...baseRepositoryConfigShape, ...sftpRepositoryConfigShape },
} as const;
export const COMPRESSION_MODES = { export const COMPRESSION_MODES = {
off: "off", off: "off",
auto: "auto", auto: "auto",

View file

@ -10,16 +10,18 @@ export const BACKEND_TYPES = {
export type BackendType = keyof typeof BACKEND_TYPES; export type BackendType = keyof typeof BACKEND_TYPES;
export const nfsConfigSchema = type({ export const nfsConfigShape = {
backend: "'nfs'", backend: "'nfs'",
server: "string", server: "string",
exportPath: "string", exportPath: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049), port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
version: "'3' | '4' | '4.1'", version: "'3' | '4' | '4.1'",
readOnly: "boolean?", readOnly: "boolean?",
}); } as const;
export const smbConfigSchema = type({ export const nfsConfigSchema = type(nfsConfigShape);
export const smbConfigShape = {
backend: "'smb'", backend: "'smb'",
server: "string", server: "string",
share: "string", share: "string",
@ -29,15 +31,19 @@ export const smbConfigSchema = type({
domain: "string?", domain: "string?",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445), port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
readOnly: "boolean?", readOnly: "boolean?",
}); } as const;
export const directoryConfigSchema = type({ export const smbConfigSchema = type(smbConfigShape);
export const directoryConfigShape = {
backend: "'directory'", backend: "'directory'",
path: "string", path: "string",
readOnly: "false?", readOnly: "false?",
}); } as const;
export const webdavConfigSchema = type({ export const directoryConfigSchema = type(directoryConfigShape);
export const webdavConfigShape = {
backend: "'webdav'", backend: "'webdav'",
server: "string", server: "string",
path: "string", path: "string",
@ -46,19 +52,31 @@ export const webdavConfigSchema = type({
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80), port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
readOnly: "boolean?", readOnly: "boolean?",
ssl: "boolean?", ssl: "boolean?",
}); } as const;
export const rcloneConfigSchema = type({ export const webdavConfigSchema = type(webdavConfigShape);
export const rcloneConfigShape = {
backend: "'rclone'", backend: "'rclone'",
remote: "string", remote: "string",
path: "string", path: "string",
readOnly: "boolean?", readOnly: "boolean?",
}); } as const;
export const rcloneConfigSchema = type(rcloneConfigShape);
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema); export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer; export type BackendConfig = typeof volumeConfigSchema.infer;
export const VOLUME_CONFIG_SHAPES = {
nfs: nfsConfigShape,
smb: smbConfigShape,
webdav: webdavConfigShape,
directory: directoryConfigShape,
rclone: rcloneConfigShape,
} as const;
export const BACKEND_STATUS = { export const BACKEND_STATUS = {
mounted: "mounted", mounted: "mounted",
unmounted: "unmounted", unmounted: "unmounted",

View file

@ -11,8 +11,12 @@ import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { sendNotification } from "../../utils/shoutrrr"; import { sendNotification } from "../../utils/shoutrrr";
import { buildShoutrrrUrl } from "./builders"; import { buildShoutrrrUrl } from "./builders";
import type { NotificationConfig, NotificationEvent } from "~/schemas/notifications"; import { NOTIFICATION_CONFIG_SHAPES, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { stripDiscriminatedUnion } from "~/utils/object";
const stripToNotificationConfig = (config: NotificationConfig): NotificationConfig =>
stripDiscriminatedUnion(config, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
const listDestinations = async () => { const listDestinations = async () => {
const destinations = await db.query.notificationDestinationsTable.findMany({ const destinations = await db.query.notificationDestinationsTable.findMany({
@ -138,13 +142,14 @@ const createDestination = async (name: string, config: NotificationConfig) => {
throw new ConflictError("Notification destination with this name already exists"); throw new ConflictError("Notification destination with this name already exists");
} }
const encryptedConfig = await encryptSensitiveFields(config); const processedConfig = stripToNotificationConfig(config);
const encryptedConfig = await encryptSensitiveFields(processedConfig);
const [created] = await db const [created] = await db
.insert(notificationDestinationsTable) .insert(notificationDestinationsTable)
.values({ .values({
name: slug, name: slug,
type: config.type, type: processedConfig.type,
config: encryptedConfig, config: encryptedConfig,
}) })
.returning(); .returning();
@ -188,9 +193,10 @@ const updateDestination = async (
} }
if (updates.config !== undefined) { if (updates.config !== undefined) {
const encryptedConfig = await encryptSensitiveFields(updates.config); const processedConfig = stripToNotificationConfig(updates.config);
const encryptedConfig = await encryptSensitiveFields(processedConfig);
updateData.config = encryptedConfig; updateData.config = encryptedConfig;
updateData.type = updates.config.type; updateData.type = processedConfig.type;
} }
const [updated] = await db const [updated] = await db

View file

@ -9,7 +9,11 @@ import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import { repoMutex } from "../../core/repository-mutex"; import { repoMutex } from "../../core/repository-mutex";
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic"; import { REPOSITORY_CONFIG_SHAPES, type CompressionMode, type OverwriteMode, type RepositoryConfig } from "~/schemas/restic";
import { stripDiscriminatedUnion } from "~/utils/object";
const stripToRepositoryConfig = (config: RepositoryConfig): RepositoryConfig =>
stripDiscriminatedUnion(config, "backend", REPOSITORY_CONFIG_SHAPES) as unknown as RepositoryConfig;
const listRepositories = async () => { const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({}); const repositories = await db.query.repositoriesTable.findMany({});
@ -65,9 +69,9 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const shortId = generateShortId(); const shortId = generateShortId();
let processedConfig = config; let processedConfig = stripToRepositoryConfig(config);
if (config.backend === "local") { if (processedConfig.backend === "local") {
processedConfig = { ...config, name: shortId }; processedConfig = { ...processedConfig, name: shortId };
} }
const encryptedConfig = await encryptConfig(processedConfig); const encryptedConfig = await encryptConfig(processedConfig);
@ -78,7 +82,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
id, id,
shortId, shortId,
name: slug, name: slug,
type: config.backend, type: processedConfig.backend,
config: encryptedConfig, config: encryptedConfig,
compressionMode: compressionMode ?? "auto", compressionMode: compressionMode ?? "auto",
status: "unknown", status: "unknown",
@ -91,7 +95,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
let error: string | null = null; let error: string | null = null;
if (config.isExistingRepository) { if (processedConfig.isExistingRepository) {
const result = await restic const result = await restic
.snapshots(encryptedConfig) .snapshots(encryptedConfig)
.then(() => ({ error: null })) .then(() => ({ error: null }))

View file

@ -16,7 +16,11 @@ import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers"; import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes"; import { VOLUME_CONFIG_SHAPES, type BackendConfig } from "~/schemas/volumes";
import { stripDiscriminatedUnion } from "~/utils/object";
const stripToBackendConfig = (config: BackendConfig): BackendConfig =>
stripDiscriminatedUnion(config, "backend", VOLUME_CONFIG_SHAPES) as unknown as BackendConfig;
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> { async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
switch (config.backend) { switch (config.backend) {
@ -53,7 +57,8 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
} }
const shortId = generateShortId(); const shortId = generateShortId();
const encryptedConfig = await encryptSensitiveFields(backendConfig); const processedConfig = stripToBackendConfig(backendConfig);
const encryptedConfig = await encryptSensitiveFields(processedConfig);
const [created] = await db const [created] = await db
.insert(volumesTable) .insert(volumesTable)
@ -61,7 +66,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
shortId, shortId,
name: slug, name: slug,
config: encryptedConfig, config: encryptedConfig,
type: backendConfig.backend, type: processedConfig.backend,
}) })
.returning(); .returning();
@ -192,14 +197,15 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await backend.unmount(); await backend.unmount();
} }
const encryptedConfig = volumeData.config ? await encryptSensitiveFields(volumeData.config) : undefined; const processedConfig = volumeData.config ? stripToBackendConfig(volumeData.config) : undefined;
const encryptedConfig = processedConfig ? await encryptSensitiveFields(processedConfig) : undefined;
const [updated] = await db const [updated] = await db
.update(volumesTable) .update(volumesTable)
.set({ .set({
name: newName, name: newName,
config: encryptedConfig, config: encryptedConfig,
type: volumeData.config?.backend, type: processedConfig?.backend,
autoRemount: volumeData.autoRemount, autoRemount: volumeData.autoRemount,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
@ -226,17 +232,18 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const testConnection = async (backendConfig: BackendConfig) => { const testConnection = async (backendConfig: BackendConfig) => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
const sanitizedConfig = stripToBackendConfig(backendConfig);
const mockVolume = { const mockVolume = {
id: 0, id: 0,
shortId: "test", shortId: "test",
name: "test-connection", name: "test-connection",
path: tempDir, path: tempDir,
config: backendConfig, config: sanitizedConfig,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
lastHealthCheck: Date.now(), lastHealthCheck: Date.now(),
type: backendConfig.backend, type: sanitizedConfig.backend,
status: "unmounted" as const, status: "unmounted" as const,
lastError: null, lastError: null,
autoRemount: true, autoRemount: true,

View file

@ -1,3 +1,11 @@
/**
* Deeply removes empty-ish values from an object/array tree.
*
* - Arrays: maps + filters out `undefined`, `null`, and empty strings.
* - Objects: recursively cleans values and drops keys whose cleaned value is `undefined` or "".
*
* Note: this function is intended for building payloads; it does not preserve object prototypes.
*/
export function deepClean<T>(obj: T): T { export function deepClean<T>(obj: T): T {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(deepClean).filter((v) => v !== undefined && v !== null && v !== "") as T; return obj.map(deepClean).filter((v) => v !== undefined && v !== null && v !== "") as T;
@ -12,3 +20,60 @@ export function deepClean<T>(obj: T): T {
} }
return obj; return obj;
} }
/**
* A "shape" describes the allowed top-level keys for an object.
*
* The values are irrelevant; only the keys matter.
*/
type Shape = Record<string, unknown>;
/**
* Strips an object to only the keys present in the provided shape.
*
* - This is a shallow operation (top-level keys only).
* - Missing keys become `undefined` in the returned object.
*
* This is used to avoid persisting polluted objects (e.g. form state) into DB `config` JSON blobs.
*/
export function stripToShape<T extends Record<string, unknown>, S extends Shape>(obj: T, shape: S): {
[K in keyof S]: K extends keyof T ? T[K] : undefined;
} {
const result: Record<string, unknown> = {};
for (const key of Object.keys(shape)) {
result[key] = (obj as Record<string, unknown>)[key];
}
return result as {
[K in keyof S]: K extends keyof T ? T[K] : undefined;
};
}
/**
* Strips a discriminated-union object to only the keys allowed for its variant.
*
* Example: given `discriminantKey = "backend"` and a map of backend -> shape,
* this returns `stripToShape(obj, shapes[obj.backend])`.
*
* Fallback behavior:
* - If the discriminant value is not a string, or there is no matching shape,
* the original object is returned unchanged.
*/
export function stripDiscriminatedUnion<
T extends Record<string, unknown>,
DiscriminantKey extends keyof T,
Shapes extends Record<string, Shape>,
>(
obj: T,
discriminantKey: DiscriminantKey,
shapes: Shapes,
): Record<string, unknown> {
const discriminant = obj[discriminantKey];
if (typeof discriminant !== "string") {
return obj;
}
const shape = shapes[discriminant];
if (!shape) {
return obj;
}
return stripToShape(obj, shape);
}