refactor: implement shape stripping for notification, repository, and volume configurations
This commit is contained in:
parent
1ea5c042f5
commit
aa957711e5
15 changed files with 251 additions and 76 deletions
|
|
@ -17,7 +17,8 @@ import { Input } from "~/client/components/ui/input";
|
|||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
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({
|
||||
name: "2<=string<=32",
|
||||
|
|
@ -26,6 +27,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|||
|
||||
export type NotificationFormValues = typeof formSchema.inferIn;
|
||||
|
||||
export const toNotificationConfig = (values: NotificationFormValues): NotificationConfig =>
|
||||
stripDiscriminatedUnion(values, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
|
||||
|
||||
type Props = {
|
||||
onSubmit: (values: NotificationFormValues) => void;
|
||||
mode?: "create" | "update";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui
|
|||
import { parseError } from "~/client/lib/errors";
|
||||
import type { Route } from "./+types/create-notification";
|
||||
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 = {
|
||||
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
|
||||
|
|
@ -38,7 +38,7 @@ export default function CreateNotification() {
|
|||
});
|
||||
|
||||
const handleSubmit = (values: NotificationFormValues) => {
|
||||
createNotification.mutate({ body: { name: values.name, config: values } });
|
||||
createNotification.mutate({ body: { name: values.name, config: toNotificationConfig(values) } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { cn } from "~/client/lib/utils";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
|
||||
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 = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
|
|
@ -109,7 +109,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
|||
path: { id: String(data.id) },
|
||||
body: {
|
||||
name: values.name,
|
||||
config: values,
|
||||
config: toNotificationConfig(values),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -172,7 +172,12 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
|||
</AlertDescription>
|
||||
</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">
|
||||
<Button type="submit" form={formId} loading={updateDestination.isPending}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ import { SecretInput } from "../../../components/ui/secret-input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
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 { stripDiscriminatedUnion } from "~/utils/object";
|
||||
import {
|
||||
LocalRepositoryForm,
|
||||
S3RepositoryForm,
|
||||
|
|
@ -41,6 +42,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|||
|
||||
export type RepositoryFormValues = typeof formSchema.inferIn;
|
||||
|
||||
export const toRepositoryConfig = (values: RepositoryFormValues): RepositoryConfig =>
|
||||
stripDiscriminatedUnion(values, "backend", REPOSITORY_CONFIG_SHAPES) as unknown as RepositoryConfig;
|
||||
|
||||
type Props = {
|
||||
onSubmit: (values: RepositoryFormValues) => void;
|
||||
mode?: "create" | "update";
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-qu
|
|||
import {
|
||||
CreateRepositoryForm,
|
||||
type RepositoryFormValues,
|
||||
toRepositoryConfig,
|
||||
} from "~/client/modules/repositories/components/create-repository-form";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
|
|
@ -43,7 +44,7 @@ export default function CreateRepository() {
|
|||
const handleSubmit = (values: RepositoryFormValues) => {
|
||||
createRepository.mutate({
|
||||
body: {
|
||||
config: values,
|
||||
config: toRepositoryConfig(values),
|
||||
name: values.name,
|
||||
compressionMode: values.compressionMode,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { testConnectionMutation } from "../../../api-client/@tanstack/react-quer
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
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({
|
||||
name: "2<=string<=32",
|
||||
|
|
@ -31,6 +33,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|||
|
||||
export type FormValues = typeof formSchema.inferIn;
|
||||
|
||||
export const toBackendConfig = (values: FormValues): BackendConfig =>
|
||||
stripDiscriminatedUnion(values, "backend", VOLUME_CONFIG_SHAPES) as unknown as BackendConfig;
|
||||
|
||||
type Props = {
|
||||
onSubmit: (values: FormValues) => void;
|
||||
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") {
|
||||
testBackendConnection.mutate({
|
||||
body: { config: formValues },
|
||||
body: { config: toBackendConfig(formValues) },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useId } from "react";
|
|||
import { useNavigate } from "react-router";
|
||||
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, type FormValues, toBackendConfig } 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";
|
||||
|
|
@ -40,7 +40,7 @@ export default function CreateVolume() {
|
|||
const handleSubmit = (values: FormValues) => {
|
||||
createVolume.mutate({
|
||||
body: {
|
||||
config: values,
|
||||
config: toBackendConfig(values),
|
||||
name: values.name,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useState } from "react";
|
|||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -59,7 +59,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
if (pendingValues) {
|
||||
updateMutation.mutate({
|
||||
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)]">
|
||||
<Card className="p-6">
|
||||
<CreateVolumeForm
|
||||
initialValues={{ ...volume, ...volume.config }}
|
||||
initialValues={{ name: volume.name, ...volume.config }}
|
||||
onSubmit={handleSubmit}
|
||||
mode="update"
|
||||
loading={updateMutation.isPending}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const NOTIFICATION_TYPES = {
|
|||
|
||||
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||
|
||||
export const emailNotificationConfigSchema = type({
|
||||
export const emailNotificationConfigShape = {
|
||||
type: "'email'",
|
||||
smtpHost: "string",
|
||||
smtpPort: "1 <= number <= 65535",
|
||||
|
|
@ -22,59 +22,75 @@ export const emailNotificationConfigSchema = type({
|
|||
from: "string",
|
||||
to: "string[]",
|
||||
useTLS: "boolean",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const slackNotificationConfigSchema = type({
|
||||
export const emailNotificationConfigSchema = type(emailNotificationConfigShape);
|
||||
|
||||
export const slackNotificationConfigShape = {
|
||||
type: "'slack'",
|
||||
webhookUrl: "string",
|
||||
channel: "string?",
|
||||
username: "string?",
|
||||
iconEmoji: "string?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const discordNotificationConfigSchema = type({
|
||||
export const slackNotificationConfigSchema = type(slackNotificationConfigShape);
|
||||
|
||||
export const discordNotificationConfigShape = {
|
||||
type: "'discord'",
|
||||
webhookUrl: "string",
|
||||
username: "string?",
|
||||
avatarUrl: "string?",
|
||||
threadId: "string?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const gotifyNotificationConfigSchema = type({
|
||||
export const discordNotificationConfigSchema = type(discordNotificationConfigShape);
|
||||
|
||||
export const gotifyNotificationConfigShape = {
|
||||
type: "'gotify'",
|
||||
serverUrl: "string",
|
||||
token: "string",
|
||||
path: "string?",
|
||||
priority: "0 <= number <= 10",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const ntfyNotificationConfigSchema = type({
|
||||
export const gotifyNotificationConfigSchema = type(gotifyNotificationConfigShape);
|
||||
|
||||
export const ntfyNotificationConfigShape = {
|
||||
type: "'ntfy'",
|
||||
serverUrl: "string?",
|
||||
topic: "string",
|
||||
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const pushoverNotificationConfigSchema = type({
|
||||
export const ntfyNotificationConfigSchema = type(ntfyNotificationConfigShape);
|
||||
|
||||
export const pushoverNotificationConfigShape = {
|
||||
type: "'pushover'",
|
||||
userKey: "string",
|
||||
apiToken: "string",
|
||||
devices: "string?",
|
||||
priority: "-1 | 0 | 1",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const telegramNotificationConfigSchema = type({
|
||||
export const pushoverNotificationConfigSchema = type(pushoverNotificationConfigShape);
|
||||
|
||||
export const telegramNotificationConfigShape = {
|
||||
type: "'telegram'",
|
||||
botToken: "string",
|
||||
chatId: "string",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const customNotificationConfigSchema = type({
|
||||
export const telegramNotificationConfigSchema = type(telegramNotificationConfigShape);
|
||||
|
||||
export const customNotificationConfigShape = {
|
||||
type: "'custom'",
|
||||
shoutrrrUrl: "string",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const customNotificationConfigSchema = type(customNotificationConfigShape);
|
||||
|
||||
export const notificationConfigSchema = emailNotificationConfigSchema
|
||||
.or(slackNotificationConfigSchema)
|
||||
|
|
@ -87,6 +103,17 @@ export const notificationConfigSchema = emailNotificationConfigSchema
|
|||
|
||||
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 = {
|
||||
start: "start",
|
||||
success: "success",
|
||||
|
|
|
|||
|
|
@ -14,70 +14,88 @@ export const REPOSITORY_BACKENDS = {
|
|||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||
|
||||
// Common fields for all repository configs
|
||||
const baseRepositoryConfigSchema = type({
|
||||
export const baseRepositoryConfigShape = {
|
||||
isExistingRepository: "boolean?",
|
||||
customPassword: "string?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const s3RepositoryConfigSchema = type({
|
||||
const baseRepositoryConfigSchema = type(baseRepositoryConfigShape);
|
||||
|
||||
export const s3RepositoryConfigShape = {
|
||||
backend: "'s3'",
|
||||
endpoint: "string",
|
||||
bucket: "string",
|
||||
accessKeyId: "string",
|
||||
secretAccessKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const r2RepositoryConfigSchema = type({
|
||||
export const s3RepositoryConfigSchema = type(s3RepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const r2RepositoryConfigShape = {
|
||||
backend: "'r2'",
|
||||
endpoint: "string",
|
||||
bucket: "string",
|
||||
accessKeyId: "string",
|
||||
secretAccessKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const localRepositoryConfigSchema = type({
|
||||
export const r2RepositoryConfigSchema = type(r2RepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const localRepositoryConfigShape = {
|
||||
backend: "'local'",
|
||||
name: "string",
|
||||
path: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const gcsRepositoryConfigSchema = type({
|
||||
export const localRepositoryConfigSchema = type(localRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const gcsRepositoryConfigShape = {
|
||||
backend: "'gcs'",
|
||||
bucket: "string",
|
||||
projectId: "string",
|
||||
credentialsJson: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const azureRepositoryConfigSchema = type({
|
||||
export const gcsRepositoryConfigSchema = type(gcsRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const azureRepositoryConfigShape = {
|
||||
backend: "'azure'",
|
||||
container: "string",
|
||||
accountName: "string",
|
||||
accountKey: "string",
|
||||
endpointSuffix: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const rcloneRepositoryConfigSchema = type({
|
||||
export const azureRepositoryConfigSchema = type(azureRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const rcloneRepositoryConfigShape = {
|
||||
backend: "'rclone'",
|
||||
remote: "string",
|
||||
path: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const restRepositoryConfigSchema = type({
|
||||
export const rcloneRepositoryConfigSchema = type(rcloneRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const restRepositoryConfigShape = {
|
||||
backend: "'rest'",
|
||||
url: "string",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
path: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const sftpRepositoryConfigSchema = type({
|
||||
export const restRepositoryConfigSchema = type(restRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const sftpRepositoryConfigShape = {
|
||||
backend: "'sftp'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||
user: "string",
|
||||
path: "string",
|
||||
privateKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
} as const;
|
||||
|
||||
export const sftpRepositoryConfigSchema = type(sftpRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||
.or(r2RepositoryConfigSchema)
|
||||
|
|
@ -90,6 +108,17 @@ export const repositoryConfigSchema = s3RepositoryConfigSchema
|
|||
|
||||
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 = {
|
||||
off: "off",
|
||||
auto: "auto",
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ export const BACKEND_TYPES = {
|
|||
|
||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||
|
||||
export const nfsConfigSchema = type({
|
||||
export const nfsConfigShape = {
|
||||
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?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const smbConfigSchema = type({
|
||||
export const nfsConfigSchema = type(nfsConfigShape);
|
||||
|
||||
export const smbConfigShape = {
|
||||
backend: "'smb'",
|
||||
server: "string",
|
||||
share: "string",
|
||||
|
|
@ -29,15 +31,19 @@ export const smbConfigSchema = type({
|
|||
domain: "string?",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
||||
readOnly: "boolean?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const directoryConfigSchema = type({
|
||||
export const smbConfigSchema = type(smbConfigShape);
|
||||
|
||||
export const directoryConfigShape = {
|
||||
backend: "'directory'",
|
||||
path: "string",
|
||||
readOnly: "false?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const webdavConfigSchema = type({
|
||||
export const directoryConfigSchema = type(directoryConfigShape);
|
||||
|
||||
export const webdavConfigShape = {
|
||||
backend: "'webdav'",
|
||||
server: "string",
|
||||
path: "string",
|
||||
|
|
@ -46,19 +52,31 @@ export const webdavConfigSchema = type({
|
|||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
||||
readOnly: "boolean?",
|
||||
ssl: "boolean?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const rcloneConfigSchema = type({
|
||||
export const webdavConfigSchema = type(webdavConfigShape);
|
||||
|
||||
export const rcloneConfigShape = {
|
||||
backend: "'rclone'",
|
||||
remote: "string",
|
||||
path: "string",
|
||||
readOnly: "boolean?",
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const rcloneConfigSchema = type(rcloneConfigShape);
|
||||
|
||||
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema);
|
||||
|
||||
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 = {
|
||||
mounted: "mounted",
|
||||
unmounted: "unmounted",
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@ import { cryptoUtils } from "../../utils/crypto";
|
|||
import { logger } from "../../utils/logger";
|
||||
import { sendNotification } from "../../utils/shoutrrr";
|
||||
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 { stripDiscriminatedUnion } from "~/utils/object";
|
||||
|
||||
const stripToNotificationConfig = (config: NotificationConfig): NotificationConfig =>
|
||||
stripDiscriminatedUnion(config, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
|
||||
|
||||
const listDestinations = async () => {
|
||||
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");
|
||||
}
|
||||
|
||||
const encryptedConfig = await encryptSensitiveFields(config);
|
||||
const processedConfig = stripToNotificationConfig(config);
|
||||
const encryptedConfig = await encryptSensitiveFields(processedConfig);
|
||||
|
||||
const [created] = await db
|
||||
.insert(notificationDestinationsTable)
|
||||
.values({
|
||||
name: slug,
|
||||
type: config.type,
|
||||
type: processedConfig.type,
|
||||
config: encryptedConfig,
|
||||
})
|
||||
.returning();
|
||||
|
|
@ -188,9 +193,10 @@ const updateDestination = async (
|
|||
}
|
||||
|
||||
if (updates.config !== undefined) {
|
||||
const encryptedConfig = await encryptSensitiveFields(updates.config);
|
||||
const processedConfig = stripToNotificationConfig(updates.config);
|
||||
const encryptedConfig = await encryptSensitiveFields(processedConfig);
|
||||
updateData.config = encryptedConfig;
|
||||
updateData.type = updates.config.type;
|
||||
updateData.type = processedConfig.type;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import { generateShortId } from "../../utils/id";
|
|||
import { restic } from "../../utils/restic";
|
||||
import { cryptoUtils } from "../../utils/crypto";
|
||||
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 repositories = await db.query.repositoriesTable.findMany({});
|
||||
|
|
@ -65,9 +69,9 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
const id = crypto.randomUUID();
|
||||
const shortId = generateShortId();
|
||||
|
||||
let processedConfig = config;
|
||||
if (config.backend === "local") {
|
||||
processedConfig = { ...config, name: shortId };
|
||||
let processedConfig = stripToRepositoryConfig(config);
|
||||
if (processedConfig.backend === "local") {
|
||||
processedConfig = { ...processedConfig, name: shortId };
|
||||
}
|
||||
|
||||
const encryptedConfig = await encryptConfig(processedConfig);
|
||||
|
|
@ -78,7 +82,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
id,
|
||||
shortId,
|
||||
name: slug,
|
||||
type: config.backend,
|
||||
type: processedConfig.backend,
|
||||
config: encryptedConfig,
|
||||
compressionMode: compressionMode ?? "auto",
|
||||
status: "unknown",
|
||||
|
|
@ -91,7 +95,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
|
||||
let error: string | null = null;
|
||||
|
||||
if (config.isExistingRepository) {
|
||||
if (processedConfig.isExistingRepository) {
|
||||
const result = await restic
|
||||
.snapshots(encryptedConfig)
|
||||
.then(() => ({ error: null }))
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ import type { UpdateVolumeBody } from "./volume.dto";
|
|||
import { getVolumePath } from "./helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
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> {
|
||||
switch (config.backend) {
|
||||
|
|
@ -53,7 +57,8 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||
}
|
||||
|
||||
const shortId = generateShortId();
|
||||
const encryptedConfig = await encryptSensitiveFields(backendConfig);
|
||||
const processedConfig = stripToBackendConfig(backendConfig);
|
||||
const encryptedConfig = await encryptSensitiveFields(processedConfig);
|
||||
|
||||
const [created] = await db
|
||||
.insert(volumesTable)
|
||||
|
|
@ -61,7 +66,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||
shortId,
|
||||
name: slug,
|
||||
config: encryptedConfig,
|
||||
type: backendConfig.backend,
|
||||
type: processedConfig.backend,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -192,14 +197,15 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||
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
|
||||
.update(volumesTable)
|
||||
.set({
|
||||
name: newName,
|
||||
config: encryptedConfig,
|
||||
type: volumeData.config?.backend,
|
||||
type: processedConfig?.backend,
|
||||
autoRemount: volumeData.autoRemount,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
|
@ -226,17 +232,18 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||
|
||||
const testConnection = async (backendConfig: BackendConfig) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
|
||||
const sanitizedConfig = stripToBackendConfig(backendConfig);
|
||||
|
||||
const mockVolume = {
|
||||
id: 0,
|
||||
shortId: "test",
|
||||
name: "test-connection",
|
||||
path: tempDir,
|
||||
config: backendConfig,
|
||||
config: sanitizedConfig,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
lastHealthCheck: Date.now(),
|
||||
type: backendConfig.backend,
|
||||
type: sanitizedConfig.backend,
|
||||
status: "unmounted" as const,
|
||||
lastError: null,
|
||||
autoRemount: true,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
if (Array.isArray(obj)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue