diff --git a/.gitignore b/.gitignore index 6d1d89b2..f5ebc411 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ playwright/temp # OpenAPI error logs openapi-ts-error-*.log .output +tmp/ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 9ac44460..8e135a75 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -841,7 +841,7 @@ export type ListRepositoriesResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -851,7 +851,6 @@ export type ListRepositoriesResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -1034,7 +1033,7 @@ export type CreateRepositoryData = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -1044,7 +1043,6 @@ export type CreateRepositoryData = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -1279,7 +1277,7 @@ export type GetRepositoryResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -1289,7 +1287,6 @@ export type GetRepositoryResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -1387,6 +1384,172 @@ export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryRe export type UpdateRepositoryData = { body?: { compressionMode?: "auto" | "max" | "off"; + config?: + | { + accessKeyId: string; + backend: "r2"; + bucket: string; + endpoint: string; + secretAccessKey: string; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + insecureTls?: boolean; + isExistingRepository?: boolean; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + } + | { + accessKeyId: string; + backend: "s3"; + bucket: string; + endpoint: string; + secretAccessKey: string; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + insecureTls?: boolean; + isExistingRepository?: boolean; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + } + | { + accountKey: string; + accountName: string; + backend: "azure"; + container: string; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + endpointSuffix?: string; + insecureTls?: boolean; + isExistingRepository?: boolean; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + } + | { + backend: "gcs"; + bucket: string; + credentialsJson: string; + projectId: string; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + insecureTls?: boolean; + isExistingRepository?: boolean; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + } + | { + backend: "local"; + path: string; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + insecureTls?: boolean; + isExistingRepository?: boolean; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + } + | { + backend: "rclone"; + path: string; + remote: string; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + insecureTls?: boolean; + isExistingRepository?: boolean; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + } + | { + backend: "rest"; + url: string; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + insecureTls?: boolean; + isExistingRepository?: boolean; + password?: string; + path?: string; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + username?: string; + } + | { + backend: "sftp"; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + skipHostKeyCheck?: boolean; + cacert?: string; + customPassword?: string; + downloadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + insecureTls?: boolean; + isExistingRepository?: boolean; + knownHosts?: string; + uploadLimit?: { + unit?: "Gbps" | "Kbps" | "Mbps"; + value?: number; + enabled?: boolean; + }; + }; name?: string; }; path: { @@ -1397,6 +1560,10 @@ export type UpdateRepositoryData = { }; export type UpdateRepositoryErrors = { + /** + * Invalid repository update payload + */ + 400: unknown; /** * Repository not found */ @@ -1499,7 +1666,7 @@ export type UpdateRepositoryResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -1509,7 +1676,6 @@ export type UpdateRepositoryResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -2077,7 +2243,7 @@ export type ListBackupSchedulesResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -2087,7 +2253,6 @@ export type ListBackupSchedulesResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -2460,7 +2625,7 @@ export type GetBackupScheduleResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -2470,7 +2635,6 @@ export type GetBackupScheduleResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -2824,7 +2988,7 @@ export type GetBackupScheduleForVolumeResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -2834,7 +2998,6 @@ export type GetBackupScheduleForVolumeResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -3400,7 +3563,7 @@ export type GetScheduleMirrorsResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -3410,7 +3573,6 @@ export type GetScheduleMirrorsResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; @@ -3620,7 +3782,7 @@ export type UpdateScheduleMirrorsResponses = { } | { backend: "local"; - name: string; + path: string; cacert?: string; customPassword?: string; downloadLimit?: { @@ -3630,7 +3792,6 @@ export type UpdateScheduleMirrorsResponses = { }; insecureTls?: boolean; isExistingRepository?: boolean; - path?: string; uploadLimit?: { unit?: "Gbps" | "Kbps" | "Mbps"; value?: number; diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index f87d7110..bcd96847 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -172,8 +172,8 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
e.stopPropagation()} className="hover:underline" > diff --git a/app/client/lib/constants.ts b/app/client/lib/constants.ts deleted file mode 100644 index fc2c499a..00000000 --- a/app/client/lib/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; -export const REGISTRATION_ENABLED_KEY = "registrations_enabled"; diff --git a/app/client/modules/backups/components/backup-card.tsx b/app/client/modules/backups/components/backup-card.tsx index c8a98bff..99b1adc2 100644 --- a/app/client/modules/backups/components/backup-card.tsx +++ b/app/client/modules/backups/components/backup-card.tsx @@ -7,7 +7,7 @@ import { Link } from "@tanstack/react-router"; export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => { return ( - +
diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 0a672c80..a12942bf 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -95,11 +95,11 @@ export const SnapshotFileBrowser = (props: Props) => {
diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index a811b149..64afcb15 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -33,6 +33,9 @@ import { SftpRepositoryForm, AdvancedForm, } from "./repository-forms"; +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", @@ -51,8 +54,8 @@ type Props = { className?: string; }; -const defaultValuesForType = { - local: { backend: "local" as const, compressionMode: "auto" as const }, +const defaultValuesForType = (repoBase: string) => ({ + local: { backend: "local" as const, compressionMode: "auto" as const, path: repoBase }, s3: { backend: "s3" as const, compressionMode: "auto" as const }, r2: { backend: "r2" as const, compressionMode: "auto" as const }, gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, @@ -60,7 +63,7 @@ const defaultValuesForType = { rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, rest: { backend: "rest" as const, compressionMode: "auto" as const }, sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false }, -}; +}); export const CreateRepositoryForm = ({ onSubmit, @@ -70,6 +73,12 @@ export const CreateRepositoryForm = ({ loading, className, }: Props) => { + const getConstants = useServerFn(getServerConstants); + const { data: constants } = useSuspenseQuery({ + queryKey: ["server-constants"], + queryFn: getConstants, + }); + const form = useForm({ resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), defaultValues: initialValues, @@ -124,10 +133,13 @@ export const CreateRepositoryForm = ({ name: form.getValues().name, isExistingRepository: form.getValues().isExistingRepository, customPassword: form.getValues().customPassword, - ...defaultValuesForType[value as keyof typeof defaultValuesForType], + ...defaultValuesForType(constants.REPOSITORY_BASE)[ + value as keyof ReturnType + ], }); }} value={field.value} + disabled={mode === "update"} > @@ -192,6 +204,7 @@ export const CreateRepositoryForm = ({ { field.onChange(checked); if (!checked) { diff --git a/app/client/modules/repositories/components/repository-forms/local-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/local-repository-form.tsx index b310c8eb..f9efa2f4 100644 --- a/app/client/modules/repositories/components/repository-forms/local-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/local-repository-form.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import type { UseFormReturn } from "react-hook-form"; import { Check, Pencil, X, AlertTriangle } from "lucide-react"; -import { REPOSITORY_BASE } from "~/client/lib/constants"; import { Button } from "../../../../components/ui/button"; import { FormItem, FormLabel, FormDescription, FormField, FormControl } from "../../../../components/ui/form"; import { DirectoryBrowser } from "../../../../components/directory-browser"; @@ -16,6 +15,9 @@ import { AlertDialogTitle, } from "../../../../components/ui/alert-dialog"; import type { RepositoryFormValues } from "../create-repository-form"; +import { useServerFn } from "@tanstack/react-start"; +import { getServerConstants } from "~/server/lib/functions/server-constants"; +import { useSuspenseQuery } from "@tanstack/react-query"; type Props = { form: UseFormReturn; @@ -25,6 +27,12 @@ export const LocalRepositoryForm = ({ form }: Props) => { const [showPathBrowser, setShowPathBrowser] = useState(false); const [showPathWarning, setShowPathWarning] = useState(false); + const getConstants = useServerFn(getServerConstants); + const { data: constants } = useSuspenseQuery({ + queryKey: ["server-constants"], + queryFn: getConstants, + }); + return ( {
- {field.value || REPOSITORY_BASE} + {field.value || constants.REPOSITORY_BASE}
diff --git a/app/client/modules/repositories/routes/edit-repository.tsx b/app/client/modules/repositories/routes/edit-repository.tsx new file mode 100644 index 00000000..0f875b83 --- /dev/null +++ b/app/client/modules/repositories/routes/edit-repository.tsx @@ -0,0 +1,176 @@ +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { Database } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { + CreateRepositoryForm, + type RepositoryFormValues, +} from "~/client/modules/repositories/components/create-repository-form"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/client/components/ui/alert-dialog"; +import { Button } from "~/client/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; +import { parseError } from "~/client/lib/errors"; +import { Alert, AlertDescription } from "~/client/components/ui/alert"; +import type { RepositoryConfig } from "~/schemas/restic"; +import { useNavigate } from "@tanstack/react-router"; + +const riskyLocationFieldsByBackend = { + local: ["path"], + s3: ["endpoint", "bucket"], + r2: ["endpoint", "bucket"], + gcs: ["bucket", "projectId"], + azure: ["container", "accountName", "endpointSuffix"], + rest: ["url", "path"], + sftp: ["host", "port", "path", "user"], + rclone: ["remote", "path"], +} as const; + +const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => { + const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? []; + + return fields.some( + (field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryFormValues], + ); +}; + +export function EditRepositoryPage({ repositoryId }: { repositoryId: string }) { + const navigate = useNavigate(); + const [showRiskConfirm, setShowRiskConfirm] = useState(false); + const [pendingValues, setPendingValues] = useState(null); + + const { data: repository } = useSuspenseQuery({ + ...getRepositoryOptions({ path: { id: repositoryId } }), + }); + + const updateRepository = useMutation({ + ...updateRepositoryMutation(), + onSuccess: async (data) => { + toast.success("Repository updated successfully"); + setShowRiskConfirm(false); + setPendingValues(null); + void navigate({ to: `/repositories/${data.shortId}` }); + }, + onError: (error) => { + toast.error("Failed to update repository", { + description: parseError(error)?.message, + }); + }, + }); + + const initialConfig = repository.config as RepositoryConfig; + const initialValues: RepositoryFormValues = { + ...initialConfig, + name: repository.name, + compressionMode: repository.compressionMode ?? "auto", + }; + + const submitUpdate = (values: RepositoryFormValues) => { + updateRepository.mutate({ + path: { id: repositoryId }, + body: { + name: values.name, + compressionMode: values.compressionMode, + config: values, + }, + }); + }; + + const handleSubmit = (values: RepositoryFormValues) => { + const nextConfig = values; + + if (hasRiskyLocationChange(initialConfig, nextConfig)) { + setPendingValues(values); + setShowRiskConfirm(true); + return; + } + + submitUpdate(values); + }; + + const handleSaveAnyway = () => { + if (!pendingValues) { + return; + } + + submitUpdate(pendingValues); + }; + + const handleRiskConfirmOpenChange = (open: boolean) => { + setShowRiskConfirm(open); + if (!open) { + setPendingValues(null); + } + }; + + return ( + <> +
+ + +
+
+ +
+ Edit Repository +
+
+ + {updateRepository.isError && ( + + + Failed to update repository: +
+ {parseError(updateRepository.error)?.message} +
+
+ )} + + +
+ +
+
+
+
+ + + + + Repository location changed + + Changing endpoint, bucket, host, or path fields may point to a different repository location. Before + saving, ensure the repository already exists at the new target. + + + + Cancel + + Save anyway + + + + + + ); +} diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index 3460db62..3fd8dc36 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -8,7 +8,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; export default function RepositoryDetailsPage({ repositoryId }: { repositoryId: string }) { const navigate = useNavigate(); - const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId" }); + const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId/" }); const activeTab = tab || "info"; const { data } = useSuspenseQuery({ diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index a305164b..96298539 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -14,7 +14,7 @@ import { Database } from "lucide-react"; import { Link, useParams } from "@tanstack/react-router"; export const SnapshotError = () => { - const { repoId } = useParams({ from: "/(dashboard)/repositories/$repoId/$snapshotId" }); + const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" }); return ( @@ -27,7 +27,7 @@ export const SnapshotError = () => { ({ tab: "snapshots" })} - params={{ repositoryId: repoId }} + params={{ repositoryId }} > @@ -125,9 +125,9 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId Backup Schedule:

{backupSchedule?.name} diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx index 41c1c66a..b19170c3 100644 --- a/app/client/modules/repositories/tabs/info.tsx +++ b/app/client/modules/repositories/tabs/info.tsx @@ -1,33 +1,27 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; -import { Check, Save, Square, Stethoscope, Trash2, Unlock } from "lucide-react"; +import { Pencil, Square, Stethoscope, Trash2, Unlock } from "lucide-react"; import { Card } from "~/client/components/ui/card"; import { Button } from "~/client/components/ui/button"; -import { Input } from "~/client/components/ui/input"; -import { Label } from "~/client/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, - AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; import type { Repository } from "~/client/lib/types"; -import { REPOSITORY_BASE } from "~/client/lib/constants"; import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime"; import { cancelDoctorMutation, deleteRepositoryMutation, startDoctorMutation, unlockRepositoryMutation, - updateRepositoryMutation, } from "~/client/api-client/@tanstack/react-query.gen"; -import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; +import type { RepositoryConfig } from "~/schemas/restic"; import { DoctorReport } from "../components/doctor-report"; import { parseError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; @@ -37,38 +31,16 @@ type Props = { }; const getEffectiveLocalPath = (repository: Repository): string | null => { - if (repository.type !== "local") return null; - const config = repository.config as { name: string; path?: string; isExistingRepository?: boolean }; - - if (config.isExistingRepository) { - return config.path ?? null; - } - - const basePath = config.path || REPOSITORY_BASE; - return `${basePath}/${config.name}`; + if (repository.config.backend !== "local") return null; + return repository.config.path; }; export const RepositoryInfoTabContent = ({ repository }: Props) => { - const [name, setName] = useState(repository.name); - const [compressionMode, setCompressionMode] = useState(repository.compressionMode || "off"); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const navigate = useNavigate(); const effectiveLocalPath = getEffectiveLocalPath(repository); - const updateMutation = useMutation({ - ...updateRepositoryMutation(), - onSuccess: () => { - toast.success("Repository updated successfully"); - setShowConfirmDialog(false); - }, - onError: (error) => { - toast.error("Failed to update repository", { description: error.message, richColors: true }); - setShowConfirmDialog(false); - }, - }); - const deleteRepo = useMutation({ ...deleteRepositoryMutation(), onSuccess: () => { @@ -115,48 +87,40 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => { }, }); - const handleSubmit = (e: React.SubmitEvent) => { - e.preventDefault(); - setShowConfirmDialog(true); - }; - - const confirmUpdate = () => { - updateMutation.mutate({ - path: { id: repository.id }, - body: { name, compressionMode }, - }); - }; - const handleConfirmDelete = () => { setShowDeleteConfirm(false); deleteRepo.mutate({ path: { id: repository.id } }); }; - const hasChanges = - name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off"); - const config = repository.config as RepositoryConfig; return ( <> - -

-
-
- Repository Settings -
-
- {repository.status === "doctor" ? ( - - ) : ( + +
+
+ Repository Settings +
+
+ + {repository.status === "doctor" ? ( + + ) : ( -
-
-
-
- - setName(e.target.value)} - placeholder="Repository name" - maxLength={32} - minLength={2} - /> -

Unique identifier for the repository.

-
-
- - -

Compression level for new data.

-
-
- -
-

Repository Information

-
-
-
Backend
-

{repository.type}

-
-
-
Status
-

{repository.status || "unknown"}

-
- {effectiveLocalPath && ( -
-
Local path
-

{effectiveLocalPath}

-
- )} -
-
Created at
-

{formatDateTime(repository.createdAt)}

-
-
-
Last checked
-

{formatTimeAgo(repository.lastChecked)}

-
- {config.cacert && ( -
-
CA Certificate
-

- configured -

-
- )} - {"insecureTls" in config && ( -
-
TLS Certificate Validation
-

- {config.insecureTls ? ( - disabled - ) : ( - enabled - )} -

-
- )} -
-
- - {repository.lastError && ( -
-
-

Last Error

-
-
-

{repository.lastError}

-
-
- )} - -
-

Configuration

-
-
{JSON.stringify(repository.config, null, 2)}
-
-
- - - -
-
- -
+
- - - - Update repository - Are you sure you want to update the repository settings? - - - Cancel - - - Update - - - - +
+

Current Configuration

+
+
+
Name
+

{repository.name}

+
+
+
Compression mode
+

{repository.compressionMode || "off"}

+
+
+
+ +
+

Repository Information

+
+
+
Backend
+

{repository.type}

+
+
+
Status
+

{repository.status || "unknown"}

+
+ {effectiveLocalPath && ( +
+
Local path
+

{effectiveLocalPath}

+
+ )} +
+
Created at
+

{formatDateTime(repository.createdAt)}

+
+
+
Last checked
+

{formatTimeAgo(repository.lastChecked)}

+
+ {config.cacert && ( +
+
CA Certificate
+

+ configured +

+
+ )} + {"insecureTls" in config && ( +
+
TLS Certificate Validation
+

+ {config.insecureTls ? ( + disabled + ) : ( + enabled + )} +

+
+ )} +
+
+ + {repository.lastError && ( +
+
+

Last Error

+
+
+

{repository.lastError}

+
+
+ )} + +
+

Configuration

+
+
{JSON.stringify(repository.config, null, 2)}
+
+
+ + + diff --git a/app/routeTree.gen.ts b/app/routeTree.gen.ts index 83a8e813..c9067284 100644 --- a/app/routeTree.gen.ts +++ b/app/routeTree.gen.ts @@ -23,14 +23,15 @@ import { Route as dashboardBackupsIndexRouteImport } from './routes/(dashboard)/ import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create' import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId' import { Route as dashboardRepositoriesCreateRouteImport } from './routes/(dashboard)/repositories/create' -import { Route as dashboardRepositoriesRepositoryIdRouteImport } from './routes/(dashboard)/repositories/$repositoryId' import { Route as dashboardNotificationsCreateRouteImport } from './routes/(dashboard)/notifications/create' import { Route as dashboardNotificationsNotificationIdRouteImport } from './routes/(dashboard)/notifications/$notificationId' import { Route as dashboardBackupsCreateRouteImport } from './routes/(dashboard)/backups/create' -import { Route as dashboardBackupsScheduleIdRouteImport } from './routes/(dashboard)/backups/$scheduleId' -import { Route as dashboardRepositoriesRepoIdSnapshotIdRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapshotId' -import { Route as dashboardRepositoriesRepoIdSnapIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapId.restore' -import { Route as dashboardBackupsBackupIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/backups/$backupId.$snapshotId.restore' +import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/index' +import { Route as dashboardBackupsBackupIdIndexRouteImport } from './routes/(dashboard)/backups/$backupId/index' +import { Route as dashboardRepositoriesRepositoryIdEditRouteImport } from './routes/(dashboard)/repositories/$repositoryId/edit' +import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index' +import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore' +import { Route as dashboardBackupsBackupIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/backups/$backupId/$snapshotId.restore' const dashboardRouteRoute = dashboardRouteRouteImport.update({ id: '/(dashboard)', @@ -105,12 +106,6 @@ const dashboardRepositoriesCreateRoute = path: '/repositories/create', getParentRoute: () => dashboardRouteRoute, } as any) -const dashboardRepositoriesRepositoryIdRoute = - dashboardRepositoriesRepositoryIdRouteImport.update({ - id: '/repositories/$repositoryId', - path: '/repositories/$repositoryId', - getParentRoute: () => dashboardRouteRoute, - } as any) const dashboardNotificationsCreateRoute = dashboardNotificationsCreateRouteImport.update({ id: '/notifications/create', @@ -128,22 +123,34 @@ const dashboardBackupsCreateRoute = dashboardBackupsCreateRouteImport.update({ path: '/backups/create', getParentRoute: () => dashboardRouteRoute, } as any) -const dashboardBackupsScheduleIdRoute = - dashboardBackupsScheduleIdRouteImport.update({ - id: '/backups/$scheduleId', - path: '/backups/$scheduleId', +const dashboardRepositoriesRepositoryIdIndexRoute = + dashboardRepositoriesRepositoryIdIndexRouteImport.update({ + id: '/repositories/$repositoryId/', + path: '/repositories/$repositoryId/', getParentRoute: () => dashboardRouteRoute, } as any) -const dashboardRepositoriesRepoIdSnapshotIdRoute = - dashboardRepositoriesRepoIdSnapshotIdRouteImport.update({ - id: '/repositories/$repoId/$snapshotId', - path: '/repositories/$repoId/$snapshotId', +const dashboardBackupsBackupIdIndexRoute = + dashboardBackupsBackupIdIndexRouteImport.update({ + id: '/backups/$backupId/', + path: '/backups/$backupId/', getParentRoute: () => dashboardRouteRoute, } as any) -const dashboardRepositoriesRepoIdSnapIdRestoreRoute = - dashboardRepositoriesRepoIdSnapIdRestoreRouteImport.update({ - id: '/repositories/$repoId/$snapId/restore', - path: '/repositories/$repoId/$snapId/restore', +const dashboardRepositoriesRepositoryIdEditRoute = + dashboardRepositoriesRepositoryIdEditRouteImport.update({ + id: '/repositories/$repositoryId/edit', + path: '/repositories/$repositoryId/edit', + getParentRoute: () => dashboardRouteRoute, + } as any) +const dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute = + dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport.update({ + id: '/repositories/$repositoryId/$snapshotId/', + path: '/repositories/$repositoryId/$snapshotId/', + getParentRoute: () => dashboardRouteRoute, + } as any) +const dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute = + dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport.update({ + id: '/repositories/$repositoryId/$snapshotId/restore', + path: '/repositories/$repositoryId/$snapshotId/restore', getParentRoute: () => dashboardRouteRoute, } as any) const dashboardBackupsBackupIdSnapshotIdRestoreRoute = @@ -159,11 +166,9 @@ export interface FileRoutesByFullPath { '/login': typeof authLoginRoute '/onboarding': typeof authOnboardingRoute '/api/$': typeof ApiSplatRoute - '/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute '/backups/create': typeof dashboardBackupsCreateRoute '/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute '/notifications/create': typeof dashboardNotificationsCreateRoute - '/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute '/repositories/create': typeof dashboardRepositoriesCreateRoute '/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/volumes/create': typeof dashboardVolumesCreateRoute @@ -172,9 +177,12 @@ export interface FileRoutesByFullPath { '/repositories/': typeof dashboardRepositoriesIndexRoute '/settings/': typeof dashboardSettingsIndexRoute '/volumes/': typeof dashboardVolumesIndexRoute - '/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute + '/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute + '/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute + '/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute '/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute - '/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute + '/repositories/$repositoryId/$snapshotId/restore': typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute + '/repositories/$repositoryId/$snapshotId/': typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -182,11 +190,9 @@ export interface FileRoutesByTo { '/login': typeof authLoginRoute '/onboarding': typeof authOnboardingRoute '/api/$': typeof ApiSplatRoute - '/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute '/backups/create': typeof dashboardBackupsCreateRoute '/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute '/notifications/create': typeof dashboardNotificationsCreateRoute - '/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute '/repositories/create': typeof dashboardRepositoriesCreateRoute '/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/volumes/create': typeof dashboardVolumesCreateRoute @@ -195,9 +201,12 @@ export interface FileRoutesByTo { '/repositories': typeof dashboardRepositoriesIndexRoute '/settings': typeof dashboardSettingsIndexRoute '/volumes': typeof dashboardVolumesIndexRoute - '/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute + '/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute + '/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute + '/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdIndexRoute '/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute - '/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute + '/repositories/$repositoryId/$snapshotId/restore': typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute + '/repositories/$repositoryId/$snapshotId': typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -207,11 +216,9 @@ export interface FileRoutesById { '/(auth)/login': typeof authLoginRoute '/(auth)/onboarding': typeof authOnboardingRoute '/api/$': typeof ApiSplatRoute - '/(dashboard)/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute '/(dashboard)/backups/create': typeof dashboardBackupsCreateRoute '/(dashboard)/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute '/(dashboard)/notifications/create': typeof dashboardNotificationsCreateRoute - '/(dashboard)/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute '/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute '/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute @@ -220,9 +227,12 @@ export interface FileRoutesById { '/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute '/(dashboard)/settings/': typeof dashboardSettingsIndexRoute '/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute - '/(dashboard)/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute + '/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute + '/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute + '/(dashboard)/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute '/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute - '/(dashboard)/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute + '/(dashboard)/repositories/$repositoryId/$snapshotId/restore': typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute + '/(dashboard)/repositories/$repositoryId/$snapshotId/': typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -232,11 +242,9 @@ export interface FileRouteTypes { | '/login' | '/onboarding' | '/api/$' - | '/backups/$scheduleId' | '/backups/create' | '/notifications/$notificationId' | '/notifications/create' - | '/repositories/$repositoryId' | '/repositories/create' | '/volumes/$volumeId' | '/volumes/create' @@ -245,9 +253,12 @@ export interface FileRouteTypes { | '/repositories/' | '/settings/' | '/volumes/' - | '/repositories/$repoId/$snapshotId' + | '/repositories/$repositoryId/edit' + | '/backups/$backupId/' + | '/repositories/$repositoryId/' | '/backups/$backupId/$snapshotId/restore' - | '/repositories/$repoId/$snapId/restore' + | '/repositories/$repositoryId/$snapshotId/restore' + | '/repositories/$repositoryId/$snapshotId/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -255,11 +266,9 @@ export interface FileRouteTypes { | '/login' | '/onboarding' | '/api/$' - | '/backups/$scheduleId' | '/backups/create' | '/notifications/$notificationId' | '/notifications/create' - | '/repositories/$repositoryId' | '/repositories/create' | '/volumes/$volumeId' | '/volumes/create' @@ -268,9 +277,12 @@ export interface FileRouteTypes { | '/repositories' | '/settings' | '/volumes' - | '/repositories/$repoId/$snapshotId' + | '/repositories/$repositoryId/edit' + | '/backups/$backupId' + | '/repositories/$repositoryId' | '/backups/$backupId/$snapshotId/restore' - | '/repositories/$repoId/$snapId/restore' + | '/repositories/$repositoryId/$snapshotId/restore' + | '/repositories/$repositoryId/$snapshotId' id: | '__root__' | '/' @@ -279,11 +291,9 @@ export interface FileRouteTypes { | '/(auth)/login' | '/(auth)/onboarding' | '/api/$' - | '/(dashboard)/backups/$scheduleId' | '/(dashboard)/backups/create' | '/(dashboard)/notifications/$notificationId' | '/(dashboard)/notifications/create' - | '/(dashboard)/repositories/$repositoryId' | '/(dashboard)/repositories/create' | '/(dashboard)/volumes/$volumeId' | '/(dashboard)/volumes/create' @@ -292,9 +302,12 @@ export interface FileRouteTypes { | '/(dashboard)/repositories/' | '/(dashboard)/settings/' | '/(dashboard)/volumes/' - | '/(dashboard)/repositories/$repoId/$snapshotId' + | '/(dashboard)/repositories/$repositoryId/edit' + | '/(dashboard)/backups/$backupId/' + | '/(dashboard)/repositories/$repositoryId/' | '/(dashboard)/backups/$backupId/$snapshotId/restore' - | '/(dashboard)/repositories/$repoId/$snapId/restore' + | '/(dashboard)/repositories/$repositoryId/$snapshotId/restore' + | '/(dashboard)/repositories/$repositoryId/$snapshotId/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -406,13 +419,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof dashboardRepositoriesCreateRouteImport parentRoute: typeof dashboardRouteRoute } - '/(dashboard)/repositories/$repositoryId': { - id: '/(dashboard)/repositories/$repositoryId' - path: '/repositories/$repositoryId' - fullPath: '/repositories/$repositoryId' - preLoaderRoute: typeof dashboardRepositoriesRepositoryIdRouteImport - parentRoute: typeof dashboardRouteRoute - } '/(dashboard)/notifications/create': { id: '/(dashboard)/notifications/create' path: '/notifications/create' @@ -434,25 +440,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof dashboardBackupsCreateRouteImport parentRoute: typeof dashboardRouteRoute } - '/(dashboard)/backups/$scheduleId': { - id: '/(dashboard)/backups/$scheduleId' - path: '/backups/$scheduleId' - fullPath: '/backups/$scheduleId' - preLoaderRoute: typeof dashboardBackupsScheduleIdRouteImport + '/(dashboard)/repositories/$repositoryId/': { + id: '/(dashboard)/repositories/$repositoryId/' + path: '/repositories/$repositoryId' + fullPath: '/repositories/$repositoryId/' + preLoaderRoute: typeof dashboardRepositoriesRepositoryIdIndexRouteImport parentRoute: typeof dashboardRouteRoute } - '/(dashboard)/repositories/$repoId/$snapshotId': { - id: '/(dashboard)/repositories/$repoId/$snapshotId' - path: '/repositories/$repoId/$snapshotId' - fullPath: '/repositories/$repoId/$snapshotId' - preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRouteImport + '/(dashboard)/backups/$backupId/': { + id: '/(dashboard)/backups/$backupId/' + path: '/backups/$backupId' + fullPath: '/backups/$backupId/' + preLoaderRoute: typeof dashboardBackupsBackupIdIndexRouteImport parentRoute: typeof dashboardRouteRoute } - '/(dashboard)/repositories/$repoId/$snapId/restore': { - id: '/(dashboard)/repositories/$repoId/$snapId/restore' - path: '/repositories/$repoId/$snapId/restore' - fullPath: '/repositories/$repoId/$snapId/restore' - preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRouteImport + '/(dashboard)/repositories/$repositoryId/edit': { + id: '/(dashboard)/repositories/$repositoryId/edit' + path: '/repositories/$repositoryId/edit' + fullPath: '/repositories/$repositoryId/edit' + preLoaderRoute: typeof dashboardRepositoriesRepositoryIdEditRouteImport + parentRoute: typeof dashboardRouteRoute + } + '/(dashboard)/repositories/$repositoryId/$snapshotId/': { + id: '/(dashboard)/repositories/$repositoryId/$snapshotId/' + path: '/repositories/$repositoryId/$snapshotId' + fullPath: '/repositories/$repositoryId/$snapshotId/' + preLoaderRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport + parentRoute: typeof dashboardRouteRoute + } + '/(dashboard)/repositories/$repositoryId/$snapshotId/restore': { + id: '/(dashboard)/repositories/$repositoryId/$snapshotId/restore' + path: '/repositories/$repositoryId/$snapshotId/restore' + fullPath: '/repositories/$repositoryId/$snapshotId/restore' + preLoaderRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport parentRoute: typeof dashboardRouteRoute } '/(dashboard)/backups/$backupId/$snapshotId/restore': { @@ -466,11 +486,9 @@ declare module '@tanstack/react-router' { } interface dashboardRouteRouteChildren { - dashboardBackupsScheduleIdRoute: typeof dashboardBackupsScheduleIdRoute dashboardBackupsCreateRoute: typeof dashboardBackupsCreateRoute dashboardNotificationsNotificationIdRoute: typeof dashboardNotificationsNotificationIdRoute dashboardNotificationsCreateRoute: typeof dashboardNotificationsCreateRoute - dashboardRepositoriesRepositoryIdRoute: typeof dashboardRepositoriesRepositoryIdRoute dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute @@ -479,19 +497,19 @@ interface dashboardRouteRouteChildren { dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute dashboardSettingsIndexRoute: typeof dashboardSettingsIndexRoute dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute - dashboardRepositoriesRepoIdSnapshotIdRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRoute + dashboardRepositoriesRepositoryIdEditRoute: typeof dashboardRepositoriesRepositoryIdEditRoute + dashboardBackupsBackupIdIndexRoute: typeof dashboardBackupsBackupIdIndexRoute + dashboardRepositoriesRepositoryIdIndexRoute: typeof dashboardRepositoriesRepositoryIdIndexRoute dashboardBackupsBackupIdSnapshotIdRestoreRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute - dashboardRepositoriesRepoIdSnapIdRestoreRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute + dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute + dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute } const dashboardRouteRouteChildren: dashboardRouteRouteChildren = { - dashboardBackupsScheduleIdRoute: dashboardBackupsScheduleIdRoute, dashboardBackupsCreateRoute: dashboardBackupsCreateRoute, dashboardNotificationsNotificationIdRoute: dashboardNotificationsNotificationIdRoute, dashboardNotificationsCreateRoute: dashboardNotificationsCreateRoute, - dashboardRepositoriesRepositoryIdRoute: - dashboardRepositoriesRepositoryIdRoute, dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute, dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute, dashboardVolumesCreateRoute: dashboardVolumesCreateRoute, @@ -500,12 +518,17 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = { dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute, dashboardSettingsIndexRoute: dashboardSettingsIndexRoute, dashboardVolumesIndexRoute: dashboardVolumesIndexRoute, - dashboardRepositoriesRepoIdSnapshotIdRoute: - dashboardRepositoriesRepoIdSnapshotIdRoute, + dashboardRepositoriesRepositoryIdEditRoute: + dashboardRepositoriesRepositoryIdEditRoute, + dashboardBackupsBackupIdIndexRoute: dashboardBackupsBackupIdIndexRoute, + dashboardRepositoriesRepositoryIdIndexRoute: + dashboardRepositoriesRepositoryIdIndexRoute, dashboardBackupsBackupIdSnapshotIdRestoreRoute: dashboardBackupsBackupIdSnapshotIdRestoreRoute, - dashboardRepositoriesRepoIdSnapIdRestoreRoute: - dashboardRepositoriesRepoIdSnapIdRestoreRoute, + dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute: + dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute, + dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute: + dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute, } const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren( diff --git a/app/routes/(dashboard)/backups/$backupId.$snapshotId.restore.tsx b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx similarity index 99% rename from app/routes/(dashboard)/backups/$backupId.$snapshotId.restore.tsx rename to app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx index fb15bb24..0103e57b 100644 --- a/app/routes/(dashboard)/backups/$backupId.$snapshotId.restore.tsx +++ b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx @@ -17,7 +17,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId ...getSnapshotDetailsOptions({ path: { id: schedule.data?.repositoryId, snapshotId: params.snapshotId } }), }), context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }), - ]); + ]) return { snapshot, repository, schedule: schedule.data }; }, @@ -51,5 +51,5 @@ function RouteComponent() { snapshot={snapshot} repository={repository} /> - ); + ) } diff --git a/app/routes/(dashboard)/backups/$scheduleId.tsx b/app/routes/(dashboard)/backups/$backupId/index.tsx similarity index 84% rename from app/routes/(dashboard)/backups/$scheduleId.tsx rename to app/routes/(dashboard)/backups/$backupId/index.tsx index 7c25a3cc..3e74f9a9 100644 --- a/app/routes/(dashboard)/backups/$scheduleId.tsx +++ b/app/routes/(dashboard)/backups/$backupId/index.tsx @@ -9,22 +9,22 @@ import { } from "~/client/api-client/@tanstack/react-query.gen"; import { ScheduleDetailsPage } from "~/client/modules/backups/routes/backup-details"; -export const Route = createFileRoute("/(dashboard)/backups/$scheduleId")({ +export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({ component: RouteComponent, loader: async ({ params, context }) => { - const { scheduleId } = params; + const { backupId } = params; const [schedule, notifs, repos, scheduleNotifs, mirrors] = await Promise.all([ - context.queryClient.ensureQueryData({ ...getBackupScheduleOptions({ path: { scheduleId } }) }), + context.queryClient.ensureQueryData({ ...getBackupScheduleOptions({ path: { scheduleId: backupId } }) }), context.queryClient.ensureQueryData({ ...listNotificationDestinationsOptions() }), context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }), - context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { scheduleId } }) }), - context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { scheduleId } }) }), - ]); + context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { scheduleId: backupId } }) }), + context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { scheduleId: backupId } }) }), + ]) void context.queryClient.prefetchQuery({ ...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.shortId } }), - }); + }) return { schedule, notifs, repos, scheduleNotifs, mirrors }; }, @@ -47,7 +47,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$scheduleId")({ function RouteComponent() { const loaderData = Route.useLoaderData(); - const { scheduleId } = Route.useParams(); + const { backupId } = Route.useParams(); - return ; + return ; } diff --git a/app/routes/(dashboard)/repositories/$repoId.$snapshotId.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/index.tsx similarity index 73% rename from app/routes/(dashboard)/repositories/$repoId.$snapshotId.tsx rename to app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/index.tsx index 4b1c470e..be3a7130 100644 --- a/app/routes/(dashboard)/repositories/$repoId.$snapshotId.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/index.tsx @@ -6,32 +6,32 @@ import { } from "~/client/api-client/@tanstack/react-query.gen"; import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details"; -export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapshotId")({ +export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({ component: RouteComponent, errorComponent: (e) =>
{e.error.message}
, loader: async ({ params, context }) => { const res = await context.queryClient.ensureQueryData({ - ...getRepositoryOptions({ path: { id: params.repoId } }), - }); + ...getRepositoryOptions({ path: { id: params.repositoryId } }), + }) void context.queryClient.prefetchQuery({ ...getSnapshotDetailsOptions({ - path: { id: params.repoId, snapshotId: params.snapshotId }, + path: { id: params.repositoryId, snapshotId: params.snapshotId }, }), - }); + }) void context.queryClient.prefetchQuery({ ...listSnapshotFilesOptions({ - path: { id: params.repoId, snapshotId: params.snapshotId }, + path: { id: params.repositoryId, snapshotId: params.snapshotId }, query: { path: "/" }, }), - }); + }) return res; }, staticData: { breadcrumb: (match) => [ { label: "Repositories", href: "/repositories" }, - { label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repoId}` }, + { label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repositoryId}` }, { label: match.params.snapshotId }, ], }, @@ -47,7 +47,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapsho }); function RouteComponent() { - const { repoId, snapshotId } = Route.useParams(); + const { repositoryId, snapshotId } = Route.useParams(); - return ; + return ; } diff --git a/app/routes/(dashboard)/repositories/$repoId.$snapId.restore.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx similarity index 66% rename from app/routes/(dashboard)/repositories/$repoId.$snapId.restore.tsx rename to app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx index 702c7267..9c1ff381 100644 --- a/app/routes/(dashboard)/repositories/$repoId.$snapId.restore.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx @@ -2,30 +2,30 @@ import { createFileRoute } from "@tanstack/react-router"; import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; -export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapId/restore")({ +export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({ component: RouteComponent, errorComponent: (e) =>
{e.error.message}
, loader: async ({ params, context }) => { const [snapshot, repository] = await Promise.all([ context.queryClient.ensureQueryData({ - ...getSnapshotDetailsOptions({ path: { id: params.repoId, snapshotId: params.snapId } }), + ...getSnapshotDetailsOptions({ path: { id: params.repositoryId, snapshotId: params.snapshotId } }), }), - context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repoId } }) }), - ]); + context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repositoryId } }) }), + ]) return { snapshot, repository }; }, staticData: { breadcrumb: (match) => [ { label: "Repositories", href: "/repositories" }, - { label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repoId}` }, - { label: match.params.snapId, href: `/repositories/${match.params.repoId}/${match.params.snapId}` }, + { label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repositoryId}` }, + { label: match.params.snapshotId, href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}` }, { label: "Restore" }, ], }, head: ({ params }) => ({ meta: [ - { title: `Zerobyte - Restore Snapshot ${params.snapId}` }, + { title: `Zerobyte - Restore Snapshot ${params.snapshotId}` }, { name: "description", content: "Restore files from a backup snapshot.", @@ -35,15 +35,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapId/ }); function RouteComponent() { - const { repoId, snapId } = Route.useParams(); + const { repositoryId, snapshotId } = Route.useParams(); const { snapshot, repository } = Route.useLoaderData(); return ( - ); + ) } diff --git a/app/routes/(dashboard)/repositories/$repositoryId/edit.tsx b/app/routes/(dashboard)/repositories/$repositoryId/edit.tsx new file mode 100644 index 00000000..b9e7a345 --- /dev/null +++ b/app/routes/(dashboard)/repositories/$repositoryId/edit.tsx @@ -0,0 +1,37 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { EditRepositoryPage } from "~/client/modules/repositories/routes/edit-repository"; + +export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/edit")({ + component: RouteComponent, + errorComponent: (e) =>
{e.error.message}
, + loader: async ({ params, context }) => { + const repository = await context.queryClient.ensureQueryData({ + ...getRepositoryOptions({ path: { id: params.repositoryId } }), + }) + + return repository; + }, + staticData: { + breadcrumb: (match) => [ + { label: "Repositories", href: "/repositories" }, + { label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repositoryId}` }, + { label: "Edit" }, + ], + }, + head: ({ loaderData }) => ({ + meta: [ + { title: `Zerobyte - Edit ${loaderData?.name ?? "Repository"}` }, + { + name: "description", + content: "Edit repository configuration.", + }, + ], + }), +}); + +function RouteComponent() { + const { repositoryId } = Route.useParams(); + + return ; +} diff --git a/app/routes/(dashboard)/repositories/$repositoryId.tsx b/app/routes/(dashboard)/repositories/$repositoryId/index.tsx similarity index 97% rename from app/routes/(dashboard)/repositories/$repositoryId.tsx rename to app/routes/(dashboard)/repositories/$repositoryId/index.tsx index d9c88fd7..6c10fa95 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/index.tsx @@ -7,20 +7,20 @@ import { } from "~/client/api-client/@tanstack/react-query.gen"; import RepositoryDetailsPage from "~/client/modules/repositories/routes/repository-details"; -export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId")({ +export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")({ component: RouteComponent, errorComponent: (e) =>
{e.error.message}
, loader: async ({ params, context }) => { void context.queryClient.prefetchQuery({ ...listSnapshotsOptions({ path: { id: params.repositoryId } }), - }); + }) void context.queryClient.prefetchQuery({ ...listBackupSchedulesOptions(), - }); + }) const res = await context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repositoryId } }), - }); + }) return res; }, diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts index 4c3f7aee..c92969c5 100644 --- a/app/schemas/restic.ts +++ b/app/schemas/restic.ts @@ -58,8 +58,7 @@ export const r2RepositoryConfigSchema = type({ export const localRepositoryConfigSchema = type({ backend: "'local'", - name: "string", - path: "string?", + path: "string", }).and(baseRepositoryConfigSchema); export const gcsRepositoryConfigSchema = type({ diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index 0eff834f..eb12ed4e 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -11,3 +11,5 @@ export const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zeroby export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone"; export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE]; + +export const REGISTRATION_ENABLED_KEY = "registrations_enabled"; diff --git a/app/server/lib/auth-middlewares/only-one-user.ts b/app/server/lib/auth-middlewares/only-one-user.ts index 6f013f35..67b5bc55 100644 --- a/app/server/lib/auth-middlewares/only-one-user.ts +++ b/app/server/lib/auth-middlewares/only-one-user.ts @@ -2,7 +2,7 @@ import { db } from "~/server/db/db"; import type { AuthMiddlewareContext } from "../auth"; import { logger } from "~/server/utils/logger"; import { ForbiddenError } from "http-errors-enhanced"; -import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants"; +import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants"; export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => { const { path } = ctx; diff --git a/app/server/lib/functions/server-constants.ts b/app/server/lib/functions/server-constants.ts new file mode 100644 index 00000000..c508a6e8 --- /dev/null +++ b/app/server/lib/functions/server-constants.ts @@ -0,0 +1,19 @@ +import { createServerFn } from "@tanstack/react-start"; +import { getRequestHeaders } from "@tanstack/react-start/server"; +import { UnauthorizedError } from "http-errors-enhanced"; +import { auth } from "~/server/lib/auth"; +import { REGISTRATION_ENABLED_KEY, REPOSITORY_BASE } from "~/server/core/constants"; + +export const getServerConstants = createServerFn({ method: "GET" }).handler(async () => { + const headers = getRequestHeaders(); + const session = await auth.api.getSession({ headers }); + + if (!session?.user) { + throw new UnauthorizedError("Invalid or expired session"); + } + + return { + REPOSITORY_BASE, + REGISTRATION_ENABLED_KEY, + }; +}); diff --git a/app/server/modules/lifecycle/migrations.ts b/app/server/modules/lifecycle/migrations.ts index 69147d49..91cb793c 100644 --- a/app/server/modules/lifecycle/migrations.ts +++ b/app/server/modules/lifecycle/migrations.ts @@ -2,6 +2,7 @@ import { logger } from "../../utils/logger"; import { v00001 } from "./migrations/00001-retag-snapshots"; import { v00002 } from "./migrations/00002-isolate-restic-passwords"; import { v00003 } from "./migrations/00003-assign-organization"; +import { v00004 } from "./migrations/00004-concat-path-name"; import { sql } from "drizzle-orm"; import { appMetadataTable, usersTable } from "../../db/schema"; import { db } from "../../db/db"; @@ -36,7 +37,7 @@ type MigrationEntity = { dependsOn?: string[]; }; -const registry: MigrationEntity[] = [v00001, v00002, v00003]; +const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004]; export const runMigrations = async () => { const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); diff --git a/app/server/modules/lifecycle/migrations/00004-concat-path-name.ts b/app/server/modules/lifecycle/migrations/00004-concat-path-name.ts new file mode 100644 index 00000000..633aa698 --- /dev/null +++ b/app/server/modules/lifecycle/migrations/00004-concat-path-name.ts @@ -0,0 +1,96 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../../../db/db"; +import { repositoriesTable } from "../../../db/schema"; +import { logger } from "../../../utils/logger"; +import { toMessage } from "~/server/utils/errors"; +import { REPOSITORY_BASE } from "~/server/core/constants"; + +type MigrationError = { name: string; error: string }; + +const asString = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + + return value; +}; + +const hasValue = (value: string | null): value is string => { + return value !== null && value.trim() !== ""; +}; + +const trimTrailingSlashes = (value: string): string => { + return value.replace(/\/+$/, ""); +}; + +const isPathAlreadyMigrated = (path: string, name: string): boolean => { + const normalizedPath = trimTrailingSlashes(path); + return normalizedPath.endsWith(`/${name}`); +}; + +const buildPath = (path: string | null, name: string): string => { + if (path === null || path.trim() === "") { + return `${REPOSITORY_BASE}/${name}`; + } + + return `${trimTrailingSlashes(path)}/${name}`; +}; + +const execute = async () => { + const errors: MigrationError[] = []; + const localRepositories = await db.query.repositoriesTable.findMany({ where: { type: "local" } }); + let migratedCount = 0; + + for (const repository of localRepositories) { + try { + const configValue = repository.config as unknown; + + if (typeof configValue !== "object" || configValue === null || Array.isArray(configValue)) { + errors.push({ + name: `repository:${repository.id}`, + error: "Repository config is not a valid JSON object", + }); + continue; + } + + const config = { ...(configValue as Record) }; + const localRepositoryName = asString(config.name); + + if (!hasValue(localRepositoryName)) { + continue; + } + + const currentPath = asString(config.path); + if (hasValue(currentPath) && isPathAlreadyMigrated(currentPath, localRepositoryName)) { + continue; + } + + config.path = buildPath(currentPath, localRepositoryName); + + await db + .update(repositoriesTable) + .set({ + config: config as typeof repository.config, + updatedAt: Date.now(), + }) + .where(and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.type, "local"))); + + migratedCount += 1; + } catch (err) { + errors.push({ + name: `repository:${repository.id}`, + error: toMessage(err), + }); + } + } + + logger.info(`Migration 00004-concat-path-name updated ${migratedCount} local repositories.`); + + return { success: errors.length === 0, errors }; +}; + +export const v00004 = { + execute, + id: "00004-concat-path-name", + type: "maintenance" as const, +}; diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index 3b506ed9..1b3719df 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -1,9 +1,44 @@ import { test, describe, expect } from "bun:test"; +import crypto from "node:crypto"; import { createApp } from "~/server/app"; +import { db } from "~/server/db/db"; +import { repositoriesTable } from "~/server/db/schema"; +import { generateShortId } from "~/server/utils/id"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; +import type { RepositoryConfig } from "~/schemas/restic"; const app = createApp(); +const createRepositoryRecord = async (organizationId: string) => { + const [repository] = await db + .insert(repositoriesTable) + .values({ + id: crypto.randomUUID(), + shortId: generateShortId(), + name: `Repository-${crypto.randomUUID()}`, + type: "local", + config: { + backend: "local", + name: generateShortId(), + path: `/tmp/repository-${crypto.randomUUID()}`, + isExistingRepository: true, + }, + compressionMode: "off", + status: "error", + lastChecked: Date.now(), + lastError: "old error", + doctorResult: { + success: false, + steps: [], + completedAt: Date.now(), + }, + organizationId, + }) + .returning(); + + return repository; +}; + describe("repositories security", () => { test("should return 401 if no session cookie is provided", async () => { const res = await app.request("/api/v1/repositories"); @@ -96,3 +131,100 @@ describe("repositories security", () => { }); }); }); + +describe("repositories updates", () => { + test("PATCH updates full config and metadata using shortId", async () => { + const { token, organizationId } = await createTestSession(); + const repository = await createRepositoryRecord(organizationId); + const nextPath = `/tmp/updated-${crypto.randomUUID()}`; + + const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { + method: "PATCH", + headers: { + ...getAuthHeaders(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Updated repository", + compressionMode: "max", + config: { + backend: "local", + path: nextPath, + isExistingRepository: true, + }, + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Updated repository"); + expect(body.compressionMode).toBe("max"); + expect(body.config.backend).toBe("local"); + expect(body.config.path).toBe(nextPath); + expect(body.status).toBe("unknown"); + expect(body.lastChecked).toBeNull(); + expect(body.lastError).toBeNull(); + expect(body.doctorResult).toBeNull(); + + const updated = await db.query.repositoriesTable.findFirst({ + where: { id: repository.id }, + }); + + const config = updated?.config as Extract; + + expect(updated).toBeTruthy(); + expect(updated?.name).toBe("Updated repository"); + expect(updated?.compressionMode).toBe("max"); + expect(config.path).toBe(nextPath); + expect(updated?.status).toBe("unknown"); + expect(updated?.lastChecked).toBeNull(); + expect(updated?.lastError).toBeNull(); + expect(updated?.doctorResult).toBeNull(); + }); + + test("PATCH rejects backend changes", async () => { + const { token, organizationId } = await createTestSession(); + const repository = await createRepositoryRecord(organizationId); + + const res = await app.request(`/api/v1/repositories/${repository.id}`, { + method: "PATCH", + headers: { + ...getAuthHeaders(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + config: { + backend: "s3", + endpoint: "s3.amazonaws.com", + bucket: "bucket-name", + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }, + }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe("Repository backend cannot be changed"); + }); + + test("PATCH rejects invalid config payload", async () => { + const { token, organizationId } = await createTestSession(); + const repository = await createRepositoryRecord(organizationId); + + const res = await app.request(`/api/v1/repositories/${repository.id}`, { + method: "PATCH", + headers: { + ...getAuthHeaders(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + config: { + backend: "local", + }, + }), + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index dda6dbc7..d550deef 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -140,6 +140,7 @@ export const deleteRepositoryDto = describeRoute({ export const updateRepositoryBody = type({ name: "string?", compressionMode: type.valueOf(COMPRESSION_MODES).optional(), + config: repositoryConfigSchema.optional(), }); export type UpdateRepositoryBody = typeof updateRepositoryBody.infer; @@ -160,6 +161,9 @@ export const updateRepositoryDto = describeRoute({ }, }, }, + 400: { + description: "Invalid repository update payload", + }, 404: { description: "Repository not found", }, diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index ae49493b..20e735c7 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { and, eq } from "drizzle-orm"; -import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; +import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import { db } from "../../db/db"; import { repositoriesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; @@ -23,6 +23,7 @@ import { executeDoctor } from "./doctor"; import { logger } from "~/server/utils/logger"; import { parseRetentionCategories, type RetentionCategory } from "~/server/utils/retention-categories"; import { backupsService } from "../backups/backups.service"; +import type { UpdateRepositoryBody } from "./repositories.dto"; const runningDoctors = new Map(); @@ -80,16 +81,51 @@ const encryptConfig = async (config: RepositoryConfig): Promise => { + const decryptedConfig: Record = { ...config }; + + if (config.customPassword) { + decryptedConfig.customPassword = await cryptoUtils.resolveSecret(config.customPassword); + } + + if (config.cacert) { + decryptedConfig.cacert = await cryptoUtils.resolveSecret(config.cacert); + } + + switch (config.backend) { + case "s3": + case "r2": + decryptedConfig.accessKeyId = await cryptoUtils.resolveSecret(config.accessKeyId); + decryptedConfig.secretAccessKey = await cryptoUtils.resolveSecret(config.secretAccessKey); + break; + case "gcs": + decryptedConfig.credentialsJson = await cryptoUtils.resolveSecret(config.credentialsJson); + break; + case "azure": + decryptedConfig.accountKey = await cryptoUtils.resolveSecret(config.accountKey); + break; + case "rest": + if (config.username) { + decryptedConfig.username = await cryptoUtils.resolveSecret(config.username); + } + if (config.password) { + decryptedConfig.password = await cryptoUtils.resolveSecret(config.password); + } + break; + case "sftp": + decryptedConfig.privateKey = await cryptoUtils.resolveSecret(config.privateKey); + break; + } + + return decryptedConfig as RepositoryConfig; +}; + const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => { const organizationId = getOrganizationId(); const id = crypto.randomUUID(); const shortId = generateShortId(); let processedConfig = config; - if (config.backend === "local" && !config.isExistingRepository) { - processedConfig = { ...config, name: shortId }; - } - const encryptedConfig = await encryptConfig(processedConfig); const [created] = await db @@ -526,40 +562,76 @@ const refreshSnapshots = async (id: string) => { } }; -const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => { +const updateRepository = async (id: string, updates: UpdateRepositoryBody) => { const existing = await findRepository(id); if (!existing) { throw new NotFoundError("Repository not found"); } - const newConfig = repositoryConfigSchema(existing.config); - if (newConfig instanceof type.errors) { + const existingConfig = repositoryConfigSchema(existing.config); + if (existingConfig instanceof type.errors) { throw new InternalServerError("Invalid repository configuration"); } - const encryptedConfig = await encryptConfig(newConfig); - let newName = existing.name; - if (updates.name !== undefined && updates.name !== existing.name) { + if (updates.name) { newName = updates.name.trim(); + if (newName.length === 0) { + throw new BadRequestError("Repository name cannot be empty"); + } } + let parsedConfig = existingConfig; + if (updates.config) { + const nextConfig = repositoryConfigSchema(updates.config); + if (nextConfig instanceof type.errors) { + throw new BadRequestError("Invalid repository configuration"); + } + + if (nextConfig.backend !== existing.type) { + throw new BadRequestError("Repository backend cannot be changed"); + } + + parsedConfig = nextConfig; + } + + const decryptedExisting = existingConfig ? await decryptConfig(existingConfig) : null; + const configChanged = + updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig); + const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig; + + const updatedAt = Date.now(); + const updatePayload = { + name: newName, + compressionMode: updates.compressionMode ?? existing.compressionMode, + updatedAt, + config: encryptedConfig, + ...(configChanged + ? { + status: "unknown" as const, + lastChecked: null, + lastError: null, + doctorResult: null, + } + : {}), + }; + const [updated] = await db .update(repositoriesTable) - .set({ - name: newName, - compressionMode: updates.compressionMode ?? existing.compressionMode, - updatedAt: Date.now(), - config: encryptedConfig, - }) - .where(eq(repositoriesTable.id, existing.id)) + .set(updatePayload) + .where(and(eq(repositoriesTable.id, existing.id), eq(repositoriesTable.organizationId, existing.organizationId))) .returning(); if (!updated) { throw new InternalServerError("Failed to update repository"); } + if (configChanged) { + cache.delByPrefix(`snapshots:${existing.id}:`); + cache.delByPrefix(`ls:${existing.id}:`); + } + return { repository: updated }; }; diff --git a/app/server/modules/system/system.service.ts b/app/server/modules/system/system.service.ts index c04c83df..17260f00 100644 --- a/app/server/modules/system/system.service.ts +++ b/app/server/modules/system/system.service.ts @@ -6,7 +6,7 @@ import { cache } from "../../utils/cache"; import { logger } from "~/server/utils/logger"; import { db } from "../../db/db"; import { appMetadataTable } from "../../db/schema"; -import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants"; +import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants"; const CACHE_TTL = 60 * 60; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 8a3f9092..f51569ea 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -4,7 +4,7 @@ import path from "node:path"; import os from "node:os"; import { throttle } from "es-toolkit"; import { type } from "arktype"; -import { REPOSITORY_BASE, RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants"; +import { RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants"; import { config as appConfig } from "../core/config"; import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; @@ -42,12 +42,7 @@ const snapshotInfoSchema = type({ export const buildRepoUrl = (config: RepositoryConfig): string => { switch (config.backend) { case "local": - if (config.isExistingRepository) { - if (!config.path) throw new Error("Path is required for existing local repositories"); - return config.path; - } - - return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`; + return config.path; case "s3": return `s3:${config.endpoint}/${config.bucket}`; case "r2": { diff --git a/app/test/helpers/repository.ts b/app/test/helpers/repository.ts index dd8592cd..a8466cbe 100644 --- a/app/test/helpers/repository.ts +++ b/app/test/helpers/repository.ts @@ -11,7 +11,7 @@ export const createTestRepository = async (overrides: Partial name: faker.string.alphanumeric(10), shortId: faker.string.alphanumeric(6), config: { - name: "test-repo", + path: `/var/lib/zerobyte/repositories/${faker.string.alphanumeric(8)}`, backend: "local", }, type: "local",