fix: Entity lookup and frontend mutation patterns for config export

- Fix volumes/repositories export to prefer name lookup over ID
- Fallback to list+filter when only ID provided (services expect name)
- Refactor ExportDialog to use generated mutations instead of manual fetch
- Remove standalone /auth/verify-password endpoint (integrated into exports)
This commit is contained in:
Jakub Trávník 2025-12-03 00:12:34 +01:00
parent 2dfa15a83a
commit 9f1a7694a3
9 changed files with 1189 additions and 486 deletions

View file

@ -215,7 +215,7 @@ To export, click the "Export" button on any list page or detail page. A dialog w
- **Include recovery key** (full export only) - Include the master encryption key for all repositories
- **Include password hash** (full export only) - Include the hashed admin password for seamless migration
When exporting sensitive data (recovery key or decrypted secrets), you'll be prompted to re-enter your password as an additional security confirmation. This is a UI-level safeguard to prevent accidental exports of sensitive information.
All exports require password verification for security. You must enter your password to confirm your identity before any configuration can be exported.
Exports are downloaded as JSON files that can be used for reference or future import functionality.

View file

@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportBackupSchedules, exportFullConfig, exportNotificationDestinations, exportRepositories, exportVolumes, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportBackupSchedulesData, ExportBackupSchedulesError, ExportBackupSchedulesResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, ExportNotificationDestinationsData, ExportNotificationDestinationsError, ExportNotificationDestinationsResponse, ExportRepositoriesData, ExportRepositoriesError, ExportRepositoriesResponse, ExportVolumesData, ExportVolumesError, ExportVolumesResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/**
* Register a new user
@ -893,3 +893,88 @@ export const downloadResticPasswordMutation = (options?: Partial<Options<Downloa
};
return mutationOptions;
};
/**
* Export full configuration including all volumes, repositories, backup schedules, and notifications
*/
export const exportFullConfigMutation = (options?: Partial<Options<ExportFullConfigData>>): UseMutationOptions<ExportFullConfigResponse, ExportFullConfigError, Options<ExportFullConfigData>> => {
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, ExportFullConfigError, Options<ExportFullConfigData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportFullConfig({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Export volumes configuration
*/
export const exportVolumesMutation = (options?: Partial<Options<ExportVolumesData>>): UseMutationOptions<ExportVolumesResponse, ExportVolumesError, Options<ExportVolumesData>> => {
const mutationOptions: UseMutationOptions<ExportVolumesResponse, ExportVolumesError, Options<ExportVolumesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportVolumes({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Export repositories configuration
*/
export const exportRepositoriesMutation = (options?: Partial<Options<ExportRepositoriesData>>): UseMutationOptions<ExportRepositoriesResponse, ExportRepositoriesError, Options<ExportRepositoriesData>> => {
const mutationOptions: UseMutationOptions<ExportRepositoriesResponse, ExportRepositoriesError, Options<ExportRepositoriesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportRepositories({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Export notification destinations configuration
*/
export const exportNotificationDestinationsMutation = (options?: Partial<Options<ExportNotificationDestinationsData>>): UseMutationOptions<ExportNotificationDestinationsResponse, ExportNotificationDestinationsError, Options<ExportNotificationDestinationsData>> => {
const mutationOptions: UseMutationOptions<ExportNotificationDestinationsResponse, ExportNotificationDestinationsError, Options<ExportNotificationDestinationsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportNotificationDestinations({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Export backup schedules configuration
*/
export const exportBackupSchedulesMutation = (options?: Partial<Options<ExportBackupSchedulesData>>): UseMutationOptions<ExportBackupSchedulesResponse, ExportBackupSchedulesError, Options<ExportBackupSchedulesData>> => {
const mutationOptions: UseMutationOptions<ExportBackupSchedulesResponse, ExportBackupSchedulesError, Options<ExportBackupSchedulesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportBackupSchedules({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};

View file

@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportBackupSchedulesData, ExportBackupSchedulesErrors, ExportBackupSchedulesResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, ExportNotificationDestinationsData, ExportNotificationDestinationsErrors, ExportNotificationDestinationsResponses, ExportRepositoriesData, ExportRepositoriesErrors, ExportRepositoriesResponses, ExportVolumesData, ExportVolumesErrors, ExportVolumesResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -567,3 +567,73 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(opt
}
});
};
/**
* Export full configuration including all volumes, repositories, backup schedules, and notifications
*/
export const exportFullConfig = <ThrowOnError extends boolean = false>(options?: Options<ExportFullConfigData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportFullConfigResponses, ExportFullConfigErrors, ThrowOnError>({
url: '/api/v1/config/export',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Export volumes configuration
*/
export const exportVolumes = <ThrowOnError extends boolean = false>(options?: Options<ExportVolumesData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportVolumesResponses, ExportVolumesErrors, ThrowOnError>({
url: '/api/v1/config/export/volumes',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Export repositories configuration
*/
export const exportRepositories = <ThrowOnError extends boolean = false>(options?: Options<ExportRepositoriesData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportRepositoriesResponses, ExportRepositoriesErrors, ThrowOnError>({
url: '/api/v1/config/export/repositories',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Export notification destinations configuration
*/
export const exportNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ExportNotificationDestinationsData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportNotificationDestinationsResponses, ExportNotificationDestinationsErrors, ThrowOnError>({
url: '/api/v1/config/export/notification-destinations',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Export backup schedules configuration
*/
export const exportBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ExportBackupSchedulesData, ThrowOnError>) => {
return (options?.client ?? client).post<ExportBackupSchedulesResponses, ExportBackupSchedulesErrors, ThrowOnError>({
url: '/api/v1/config/export/backup-schedules',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};

View file

@ -2615,3 +2615,263 @@ export type DownloadResticPasswordResponses = {
};
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
export type ExportFullConfigData = {
body?: {
password: string;
includeIds?: boolean;
includePasswordHash?: boolean;
includeRecoveryKey?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export';
};
export type ExportFullConfigErrors = {
/**
* Password required for sensitive export options
*/
401: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportFullConfigError = ExportFullConfigErrors[keyof ExportFullConfigErrors];
export type ExportFullConfigResponses = {
/**
* Full configuration export
*/
200: {
admin?: {
username: string;
passwordHash?: string;
recoveryKey?: string;
} | null;
backupSchedules?: Array<unknown>;
exportedAt?: string;
notificationDestinations?: Array<unknown>;
repositories?: Array<unknown>;
version?: number;
volumes?: Array<unknown>;
};
};
export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses];
export type ExportVolumesData = {
body?: {
password: string;
id?: number | string;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
name?: string;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/volumes';
};
export type ExportVolumesErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Volume not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportVolumesError = ExportVolumesErrors[keyof ExportVolumesErrors];
export type ExportVolumesResponses = {
/**
* Volumes configuration export
*/
200: {
volumes: Array<unknown>;
};
};
export type ExportVolumesResponse = ExportVolumesResponses[keyof ExportVolumesResponses];
export type ExportRepositoriesData = {
body?: {
password: string;
id?: number | string;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
name?: string;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/repositories';
};
export type ExportRepositoriesErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Password required for sensitive export options
*/
401: {
error: string;
};
/**
* Repository not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportRepositoriesError = ExportRepositoriesErrors[keyof ExportRepositoriesErrors];
export type ExportRepositoriesResponses = {
/**
* Repositories configuration export
*/
200: {
repositories: Array<unknown>;
};
};
export type ExportRepositoriesResponse = ExportRepositoriesResponses[keyof ExportRepositoriesResponses];
export type ExportNotificationDestinationsData = {
body?: {
password: string;
id?: number | string;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
name?: string;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/notification-destinations';
};
export type ExportNotificationDestinationsErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Password required for sensitive export options
*/
401: {
error: string;
};
/**
* Notification destination not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportNotificationDestinationsError = ExportNotificationDestinationsErrors[keyof ExportNotificationDestinationsErrors];
export type ExportNotificationDestinationsResponses = {
/**
* Notification destinations configuration export
*/
200: {
notificationDestinations: Array<unknown>;
};
};
export type ExportNotificationDestinationsResponse = ExportNotificationDestinationsResponses[keyof ExportNotificationDestinationsResponses];
export type ExportBackupSchedulesData = {
body?: {
password: string;
id?: number;
includeIds?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export/backup-schedules';
};
export type ExportBackupSchedulesErrors = {
/**
* Invalid request
*/
400: {
error: string;
};
/**
* Backup schedule not found
*/
404: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportBackupSchedulesError = ExportBackupSchedulesErrors[keyof ExportBackupSchedulesErrors];
export type ExportBackupSchedulesResponses = {
/**
* Backup schedules configuration export
*/
200: {
backupSchedules: Array<unknown>;
};
};
export type ExportBackupSchedulesResponse = ExportBackupSchedulesResponses[keyof ExportBackupSchedulesResponses];

View file

@ -1,6 +1,14 @@
import { useMutation } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
exportBackupSchedulesMutation,
exportFullConfigMutation,
exportNotificationDestinationsMutation,
exportRepositoriesMutation,
exportVolumesMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox";
import {
@ -22,28 +30,8 @@ import {
SelectValue,
} from "~/client/components/ui/select";
async function verifyPassword(password: string): Promise<boolean> {
const response = await fetch("/api/v1/auth/verify-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
return response.ok;
}
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
export type ExportOptions = {
includeIds?: boolean;
includeTimestamps?: boolean;
includeRuntimeState?: boolean;
includeRecoveryKey?: boolean;
includePasswordHash?: boolean;
secretsMode?: SecretsMode;
name?: string;
id?: string | number;
};
function downloadAsJson(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
@ -56,89 +44,51 @@ function downloadAsJson(data: unknown, filename: string): void {
URL.revokeObjectURL(url);
}
async function exportFromApi(endpoint: string, filename: string, options: ExportOptions = {}): Promise<void> {
const params = new URLSearchParams();
if (options.includeIds === false) params.set("includeIds", "false");
if (options.includeTimestamps === false) params.set("includeTimestamps", "false");
if (options.includeRuntimeState === true) params.set("includeRuntimeState", "true");
if (options.includeRecoveryKey === true) params.set("includeRecoveryKey", "true");
if (options.includePasswordHash === true) params.set("includePasswordHash", "true");
if (options.secretsMode && options.secretsMode !== "exclude") params.set("secretsMode", options.secretsMode);
if (options.id !== undefined) params.set("id", String(options.id));
if (options.name) params.set("name", options.name);
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
const errorText = await res.text().catch(() => res.statusText);
throw new Error(errorText || `HTTP ${res.status}`);
}
const data = await res.json();
downloadAsJson(data, filename);
}
export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full";
type ExportConfig = {
endpoint: string;
label: string;
labelPlural: string;
getFilename: (options: ExportOptions) => string;
getFilename: (id?: string | number, name?: string) => string;
};
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
volumes: {
endpoint: "/api/v1/config/export/volumes",
label: "Volume",
labelPlural: "Volumes",
getFilename: (opts) => {
const identifier = opts.id ?? opts.name;
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `volume-${identifier}-config` : "volumes-config";
},
},
repositories: {
endpoint: "/api/v1/config/export/repositories",
label: "Repository",
labelPlural: "Repositories",
getFilename: (opts) => {
const identifier = opts.id ?? opts.name;
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `repository-${identifier}-config` : "repositories-config";
},
},
"notification-destinations": {
endpoint: "/api/v1/config/export/notification-destinations",
label: "Notification Destination",
labelPlural: "Notification Destinations",
getFilename: (opts) => {
const identifier = opts.id ?? opts.name;
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config";
},
},
"backup-schedules": {
endpoint: "/api/v1/config/export/backup-schedules",
label: "Backup Schedule",
labelPlural: "Backup Schedules",
getFilename: (opts) => (opts.id ? `backup-schedule-${opts.id}-config` : "backup-schedules-config"),
getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"),
},
full: {
endpoint: "/api/v1/config/export",
label: "Full Config",
labelPlural: "Full Config",
getFilename: () => "zerobyte-full-config",
},
};
export async function exportConfig(
entityType: ExportEntityType,
options: ExportOptions = {}
): Promise<void> {
const config = exportConfigs[entityType];
const filename = config.getFilename(options);
await exportFromApi(config.endpoint, filename, options);
}
type BaseExportDialogProps = {
entityType: ExportEntityType;
name?: string;
@ -181,10 +131,7 @@ export function ExportDialog({
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [isExporting, setIsExporting] = useState(false);
const [showPasswordStep, setShowPasswordStep] = useState(false);
const [password, setPassword] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const config = exportConfigs[entityType];
const isSingleItem = !!(name || id);
@ -192,71 +139,137 @@ export function ExportDialog({
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes";
const entityLabel = isSingleItem ? config.label : config.labelPlural;
const requiresPassword = includeRecoveryKey || secretsMode === "cleartext";
const filename = config.getFilename(id, name);
const performExport = async () => {
setIsExporting(true);
try {
await exportConfig(entityType, {
includeIds,
includeTimestamps,
includeRuntimeState,
includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined,
includePasswordHash: isFullExport ? includePasswordHash : undefined,
secretsMode: hasSecrets ? secretsMode : undefined,
name,
id,
});
toast.success(`${entityLabel} exported successfully`);
setOpen(false);
setShowPasswordStep(false);
setPassword("");
} catch (err) {
toast.error("Export failed", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
setIsExporting(false);
const handleExportSuccess = (data: unknown) => {
downloadAsJson(data, filename);
toast.success(`${entityLabel} exported successfully`);
setOpen(false);
setPassword("");
};
const handleExportError = (error: unknown) => {
const message = error && typeof error === "object" && "error" in error
? (error as { error: string }).error
: "Unknown error";
toast.error("Export failed", {
description: message,
});
};
const fullExport = useMutation({
...exportFullConfigMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const volumesExport = useMutation({
...exportVolumesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const repositoriesExport = useMutation({
...exportRepositoriesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const notificationsExport = useMutation({
...exportNotificationDestinationsMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const backupSchedulesExport = useMutation({
...exportBackupSchedulesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const getMutation = () => {
switch (entityType) {
case "full":
return fullExport;
case "volumes":
return volumesExport;
case "repositories":
return repositoriesExport;
case "notification-destinations":
return notificationsExport;
case "backup-schedules":
return backupSchedulesExport;
}
};
const handleExport = () => {
if (requiresPassword) {
setShowPasswordStep(true);
} else {
performExport();
}
};
const exportMutation = getMutation();
const handlePasswordSubmit = async (e: React.FormEvent) => {
const handleExport = (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
setIsVerifying(true);
try {
const isValid = await verifyPassword(password);
if (!isValid) {
toast.error("Incorrect password");
return;
}
// Password verified, proceed with export
await performExport();
} catch (err) {
toast.error("Verification failed", {
description: err instanceof Error ? err.message : "Unable to verify password. Please check your connection and try again.",
});
} finally {
setIsVerifying(false);
const baseBody = {
password,
includeIds,
includeTimestamps,
includeRuntimeState,
secretsMode: hasSecrets ? secretsMode : undefined,
};
switch (entityType) {
case "full":
fullExport.mutate({
body: {
...baseBody,
includeRecoveryKey,
includePasswordHash,
},
});
break;
case "volumes":
volumesExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "repositories":
repositoriesExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "notification-destinations":
notificationsExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "backup-schedules":
backupSchedulesExport.mutate({
body: {
...baseBody,
id: typeof id === "number" ? id : undefined,
},
});
break;
}
};
const handleDialogChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
setShowPasswordStep(false);
setPassword("");
}
};
@ -283,172 +296,146 @@ export function ExportDialog({
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent>
{showPasswordStep ? (
<form onSubmit={handlePasswordSubmit}>
<DialogHeader>
<DialogTitle>Confirm Export</DialogTitle>
<DialogDescription>
For security reasons, please enter your password to export sensitive information
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="export-password">Your Password</Label>
<Input
id="export-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoFocus
/>
</div>
<form onSubmit={handleExport}>
<DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle>
<DialogDescription>
{isFullExport
? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications."
: isSingleItem
? `Export the configuration for this ${config.label.toLowerCase()}.`
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-3">
<Checkbox
id="includeIds"
checked={includeIds}
onCheckedChange={(checked) => setIncludeIds(checked === true)}
/>
<Label htmlFor="includeIds" className="cursor-pointer">
Include database IDs
</Label>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setShowPasswordStep(false);
setPassword("");
}}
>
Back
</Button>
<Button type="submit" loading={isVerifying || isExporting}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</form>
) : (
<>
<DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle>
<DialogDescription>
{isFullExport
? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications."
: isSingleItem
? `Export the configuration for this ${config.label.toLowerCase()}.`
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
</DialogDescription>
</DialogHeader>
<p className="text-xs text-muted-foreground ml-7">
Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.
</p>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-3">
<Checkbox
id="includeIds"
checked={includeIds}
onCheckedChange={(checked) => setIncludeIds(checked === true)}
/>
<Label htmlFor="includeIds" className="cursor-pointer">
Include database IDs
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includeTimestamps"
checked={includeTimestamps}
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
/>
<Label htmlFor="includeTimestamps" className="cursor-pointer">
Include timestamps
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includeRuntimeState"
checked={includeRuntimeState}
onCheckedChange={(checked) => setIncludeRuntimeState(checked === true)}
/>
<Label htmlFor="includeRuntimeState" className="cursor-pointer">
Include runtime state
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include current status, health checks, and last backup information. Usually not needed for migration.
</p>
{hasSecrets && (
<>
<div className="flex items-center justify-between pt-2 border-t">
<Label className="cursor-pointer">Secrets handling</Label>
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">Exclude</SelectItem>
<SelectItem value="encrypted">Keep encrypted</SelectItem>
<SelectItem value="cleartext">Decrypt</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."}
{secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."}
{secretsMode === "cleartext" && (
<span className="text-yellow-600">
Secrets will be decrypted and exported as plaintext. Keep this export secure!
</span>
)}
</p>
</>
)}
{isFullExport && (
<>
<div className="flex items-center space-x-3 pt-2 border-t">
<Checkbox
id="includeRecoveryKey"
checked={includeRecoveryKey}
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
/>
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
Include recovery key
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
<span className="text-yellow-600 font-medium"> Security sensitive:</span> The recovery key is the master encryption key for all repositories. Keep this export secure and never share it.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includePasswordHash"
checked={includePasswordHash}
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
/>
<Label htmlFor="includePasswordHash" className="cursor-pointer">
Include password hash
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include the hashed admin password for seamless migration. The password is already securely hashed (argon2).
</p>
</>
)}
<div className="flex items-center space-x-3">
<Checkbox
id="includeTimestamps"
checked={includeTimestamps}
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
/>
<Label htmlFor="includeTimestamps" className="cursor-pointer">
Include timestamps
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleExport} loading={isExporting}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</>
)}
<div className="flex items-center space-x-3">
<Checkbox
id="includeRuntimeState"
checked={includeRuntimeState}
onCheckedChange={(checked) => setIncludeRuntimeState(checked === true)}
/>
<Label htmlFor="includeRuntimeState" className="cursor-pointer">
Include runtime state
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include current status, health checks, and last backup information. Usually not needed for migration.
</p>
{hasSecrets && (
<>
<div className="flex items-center justify-between pt-2 border-t">
<Label className="cursor-pointer">Secrets handling</Label>
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">Exclude</SelectItem>
<SelectItem value="encrypted">Keep encrypted</SelectItem>
<SelectItem value="cleartext">Decrypt</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."}
{secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."}
{secretsMode === "cleartext" && (
<span className="text-yellow-600">
Secrets will be decrypted and exported as plaintext. Keep this export secure!
</span>
)}
</p>
</>
)}
{isFullExport && (
<>
<div className="flex items-center space-x-3 pt-2 border-t">
<Checkbox
id="includeRecoveryKey"
checked={includeRecoveryKey}
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
/>
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
Include recovery key
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
<span className="text-yellow-600 font-medium"> Security sensitive:</span> The recovery key is the master encryption key for all repositories. Keep this export secure and never share it.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includePasswordHash"
checked={includePasswordHash}
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
/>
<Label htmlFor="includePasswordHash" className="cursor-pointer">
Include password hash
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include the hashed admin password for seamless migration. The password is already securely hashed (argon2).
</p>
</>
)}
<div className="space-y-2 pt-2 border-t">
<Label htmlFor="export-password">Your Password</Label>
<Input
id="export-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password to export"
required
/>
<p className="text-xs text-muted-foreground">
Password is required to verify your identity before exporting configuration.
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={exportMutation.isPending}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);

View file

@ -12,15 +12,12 @@ import {
logoutDto,
registerBodySchema,
registerDto,
verifyPasswordBodySchema,
verifyPasswordDto,
type ChangePasswordDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
type VerifyPasswordDto,
} from "./auth.dto";
import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
@ -141,27 +138,4 @@ export const authController = new Hono()
} catch (error) {
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
}
})
.post("/verify-password", verifyPasswordDto, validator("json", verifyPasswordBodySchema), async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json<VerifyPasswordDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json<VerifyPasswordDto>({ success: false, message: "Not authenticated" }, 401);
}
const body = c.req.valid("json");
const isValid = await authService.verifyPassword(session.user.id, body.password);
if (!isValid) {
return c.json<VerifyPasswordDto>({ success: false, message: "Incorrect password" }, 401);
}
return c.json<VerifyPasswordDto>({ success: true, message: "Password verified" });
});

View file

@ -148,34 +148,6 @@ export const changePasswordDto = describeRoute({
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
export const verifyPasswordBodySchema = type({
password: "string>0",
});
const verifyPasswordResponseSchema = type({
success: "boolean",
message: "string",
});
export const verifyPasswordDto = describeRoute({
description: "Verify current user password for re-authentication",
operationId: "verifyPassword",
tags: ["Auth"],
responses: {
200: {
description: "Password verification result",
content: {
"application/json": {
schema: resolver(verifyPasswordResponseSchema),
},
},
},
},
});
export type VerifyPasswordDto = typeof verifyPasswordResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer;
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
export type VerifyPasswordBody = typeof verifyPasswordBodySchema.infer;

View file

@ -1,24 +1,44 @@
import { validator } from "hono-openapi";
import { Hono } from "hono";
import type { Context } from "hono";
import { eq } from "drizzle-orm";
import { deleteCookie, getCookie } from "hono/cookie";
import {
backupSchedulesTable,
notificationDestinationsTable,
repositoriesTable,
backupScheduleNotificationsTable,
usersTable,
volumesTable,
} from "../../db/schema";
import { db } from "../../db/db";
import { logger } from "../../utils/logger";
import { RESTIC_PASS_FILE } from "../../core/constants";
import { cryptoUtils } from "../../utils/crypto";
import { authService } from "../auth/auth.service";
import { volumeService } from "../volumes/volume.service";
import { repositoriesService } from "../repositories/repositories.service";
import { notificationsService } from "../notifications/notifications.service";
import { backupsService } from "../backups/backups.service";
import {
fullExportBodySchema,
entityExportBodySchema,
backupScheduleExportBodySchema,
fullExportDto,
volumesExportDto,
repositoriesExportDto,
notificationsExportDto,
backupSchedulesExportDto,
type SecretsMode,
type FullExportBody,
type EntityExportBody,
type BackupScheduleExportBody,
} from "./config-export.dto";
// ============================================================================
// Types
// ============================================================================
type SecretsMode = "exclude" | "encrypted" | "cleartext";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: false,
sameSite: "lax" as const,
path: "/",
};
type ExportParams = {
includeIds: boolean;
@ -27,14 +47,6 @@ type ExportParams = {
excludeKeys: string[];
};
type FilterOptions = { id?: string; name?: string };
type FetchResult<T> = { data: T[] } | { error: string; status: 400 | 404 };
// ============================================================================
// Helper Functions
// ============================================================================
function omitKeys<T extends Record<string, unknown>>(obj: T, keys: string[]): Partial<T> {
const result = { ...obj };
for (const key of keys) {
@ -66,25 +78,46 @@ function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, include
];
}
/** Parse common export query parameters from request */
function parseExportParams(c: Context): ExportParams {
const includeIds = c.req.query("includeIds") !== "false";
const includeTimestamps = c.req.query("includeTimestamps") !== "false";
const includeRuntimeState = c.req.query("includeRuntimeState") === "true";
const secretsModeRaw = c.req.query("secretsMode");
const allowedSecretsModes: SecretsMode[] = ["exclude", "encrypted", "cleartext"];
const secretsMode: SecretsMode = secretsModeRaw
? (allowedSecretsModes.includes(secretsModeRaw as SecretsMode)
? (secretsModeRaw as SecretsMode)
: (() => { throw new Error("Invalid secretsMode parameter"); })())
: "exclude";
/** Parse export params from request body */
function parseExportParamsFromBody(body: {
includeIds?: boolean;
includeTimestamps?: boolean;
includeRuntimeState?: boolean;
secretsMode?: SecretsMode;
}): ExportParams {
const includeIds = body.includeIds !== false;
const includeTimestamps = body.includeTimestamps !== false;
const includeRuntimeState = body.includeRuntimeState === true;
const secretsMode: SecretsMode = body.secretsMode ?? "exclude";
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState);
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
}
/** Get filter options from request query params */
function getFilterOptions(c: Context): FilterOptions {
return { id: c.req.query("id"), name: c.req.query("name") };
/**
* Verify password for export operation.
* All exports require password verification for security.
*/
async function verifyExportPassword(
c: Context,
password: string
): Promise<{ valid: true; userId: number } | { valid: false; error: string }> {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return { valid: false, error: "Not authenticated" };
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return { valid: false, error: "Session expired" };
}
const isValid = await authService.verifyPassword(session.user.id, password);
if (!isValid) {
return { valid: false, error: "Incorrect password" };
}
return { valid: true, userId: session.user.id };
}
/**
@ -146,72 +179,6 @@ async function exportEntities<T extends Record<string, unknown>>(
return Promise.all(entities.map((e) => exportEntity(e as Record<string, unknown>, params)));
}
// ============================================================================
// Data Fetchers with Filtering
// ============================================================================
async function fetchVolumes(filter: FilterOptions): Promise<FetchResult<typeof volumesTable.$inferSelect>> {
if (filter.id) {
const id = Number.parseInt(filter.id, 10);
if (Number.isNaN(id)) return { error: "Invalid volume ID", status: 400 };
const result = await db.select().from(volumesTable).where(eq(volumesTable.id, id));
if (result.length === 0) return { error: `Volume with ID '${filter.id}' not found`, status: 404 };
return { data: result };
}
if (filter.name) {
const result = await db.select().from(volumesTable).where(eq(volumesTable.name, filter.name));
if (result.length === 0) return { error: `Volume '${filter.name}' not found`, status: 404 };
return { data: result };
}
return { data: await db.select().from(volumesTable) };
}
async function fetchRepositories(filter: FilterOptions): Promise<FetchResult<typeof repositoriesTable.$inferSelect>> {
if (filter.id) {
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(filter.id)) {
return { error: "Invalid repository ID format (expected UUID)", status: 400 };
}
const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.id, filter.id));
if (result.length === 0) return { error: `Repository with ID '${filter.id}' not found`, status: 404 };
return { data: result };
}
if (filter.name) {
const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.name, filter.name));
if (result.length === 0) return { error: `Repository '${filter.name}' not found`, status: 404 };
return { data: result };
}
return { data: await db.select().from(repositoriesTable) };
}
async function fetchNotifications(filter: FilterOptions): Promise<FetchResult<typeof notificationDestinationsTable.$inferSelect>> {
if (filter.id) {
const id = Number.parseInt(filter.id, 10);
if (Number.isNaN(id)) return { error: "Invalid notification destination ID", status: 400 };
const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id));
if (result.length === 0) return { error: `Notification destination with ID '${filter.id}' not found`, status: 404 };
return { data: result };
}
if (filter.name) {
const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.name, filter.name));
if (result.length === 0) return { error: `Notification destination '${filter.name}' not found`, status: 404 };
return { data: result };
}
return { data: await db.select().from(notificationDestinationsTable) };
}
async function fetchBackupSchedules(filter: { id?: string }): Promise<FetchResult<typeof backupSchedulesTable.$inferSelect>> {
if (filter.id) {
const id = Number.parseInt(filter.id, 10);
if (Number.isNaN(id)) return { error: "Invalid backup schedule ID", status: 400 };
const result = await db.select().from(backupSchedulesTable).where(eq(backupSchedulesTable.id, id));
if (result.length === 0) return { error: `Backup schedule with ID '${filter.id}' not found`, status: 404 };
return { data: result };
}
return { data: await db.select().from(backupSchedulesTable) };
}
/** Transform backup schedules with resolved names and notifications */
function transformBackupSchedules(
schedules: typeof backupSchedulesTable.$inferSelect[],
@ -241,34 +208,27 @@ function transformBackupSchedules(
});
}
// ============================================================================
// Controller
// ============================================================================
/**
* Config Export API
*
* Query parameters:
* - includeIds: "true" | "false" (default: "true") - Include database IDs
* - includeTimestamps: "true" | "false" (default: "true") - Include createdAt/updatedAt
* - includeRecoveryKey: "true" | "false" (default: "false") - Include recovery key (full export only)
* - includePasswordHash: "true" | "false" (default: "false") - Include admin password hash (full export only)
* - secretsMode: "exclude" | "encrypted" | "cleartext" (default: "exclude") - How to handle secrets
* - id: string (optional) - Filter by ID
* - name: string (optional) - Filter by name (not for backups)
*/
export const configExportController = new Hono()
.get("/export", async (c) => {
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
try {
const params = parseExportParams(c);
const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true";
const includePasswordHash = c.req.query("includePasswordHash") === "true";
const body = c.req.valid("json") as FullExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
const includeRecoveryKey = body.includeRecoveryKey === true;
const includePasswordHash = body.includePasswordHash === true;
// Use services to fetch data
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([
db.select().from(volumesTable),
db.select().from(repositoriesTable),
db.select().from(backupSchedulesTable),
db.select().from(notificationDestinationsTable),
volumeService.listVolumes(),
repositoriesService.listRepositories(),
backupsService.listSchedules(),
notificationsService.listDestinations(),
db.select().from(backupScheduleNotificationsTable),
db.select().from(usersTable).limit(1),
]);
@ -281,9 +241,6 @@ export const configExportController = new Hono()
backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params
);
// WARNING: As of now, volume exports may include sensitive data (e.g., SMB/NFS credentials) in cleartext.
// This is a known security limitation. Handle exported configuration files with care.
// Future PRs will implement encryption for these secrets.
const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([
exportEntities(volumes, params),
exportEntities(repositories, params),
@ -318,60 +275,169 @@ export const configExportController = new Hono()
return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500);
}
})
.get("/export/volumes", async (c) => {
.post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => {
try {
const params = parseExportParams(c);
const result = await fetchVolumes(getFilterOptions(c));
if ("error" in result) return c.json({ error: result.error }, result.status);
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
return c.json({ volumes: await exportEntities(result.data, params) });
const body = c.req.valid("json") as EntityExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
let volumes;
// Prefer name over id since volumeService.getVolume expects name (slug)
if (body.name) {
try {
const result = await volumeService.getVolume(body.name);
volumes = [result.volume];
} catch {
return c.json({ error: `Volume '${body.name}' not found` }, 404);
}
} else if (body.id !== undefined) {
// If only ID provided, find volume by numeric ID from list
const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id;
if (Number.isNaN(id)) {
return c.json({ error: "Invalid volume ID" }, 400);
}
const allVolumes = await volumeService.listVolumes();
const volume = allVolumes.find((v) => v.id === id);
if (!volume) {
return c.json({ error: `Volume with ID '${body.id}' not found` }, 404);
}
volumes = [volume];
} else {
volumes = await volumeService.listVolumes();
}
return c.json({ volumes: await exportEntities(volumes, params) });
} catch (err) {
logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500);
}
})
.get("/export/repositories", async (c) => {
.post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => {
try {
const params = parseExportParams(c);
const result = await fetchRepositories(getFilterOptions(c));
if ("error" in result) return c.json({ error: result.error }, result.status);
return c.json({ repositories: await exportEntities(result.data, params) });
const body = c.req.valid("json") as EntityExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
let repositories;
// Prefer name over id since repositoriesService.getRepository expects name (slug)
if (body.name) {
try {
const result = await repositoriesService.getRepository(body.name);
repositories = [result.repository];
} catch {
return c.json({ error: `Repository '${body.name}' not found` }, 404);
}
} else if (body.id !== undefined) {
// If only ID provided, find repository by ID from list
// Repository IDs are strings (UUIDs), not numeric
const allRepositories = await repositoriesService.listRepositories();
const repository = allRepositories.find((r) => r.id === String(body.id));
if (!repository) {
return c.json({ error: `Repository with ID '${body.id}' not found` }, 404);
}
repositories = [repository];
} else {
repositories = await repositoriesService.listRepositories();
}
return c.json({ repositories: await exportEntities(repositories, params) });
} catch (err) {
logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: `Failed to export repositories: ${err instanceof Error ? err.message : String(err)}` }, 500);
}
})
.get("/export/notification-destinations", async (c) => {
.post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => {
try {
const params = parseExportParams(c);
const result = await fetchNotifications(getFilterOptions(c));
if ("error" in result) return c.json({ error: result.error }, result.status);
return c.json({ notificationDestinations: await exportEntities(result.data, params) });
const body = c.req.valid("json") as EntityExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
let notifications;
if (body.id !== undefined) {
const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id;
if (Number.isNaN(id)) {
return c.json({ error: "Invalid notification destination ID" }, 400);
}
try {
const destination = await notificationsService.getDestination(id);
notifications = [destination];
} catch {
return c.json({ error: `Notification destination with ID '${body.id}' not found` }, 404);
}
} else if (body.name) {
// notificationsService doesn't have getByName, so we list and filter
const allDestinations = await notificationsService.listDestinations();
const destination = allDestinations.find((d) => d.name === body.name);
if (!destination) {
return c.json({ error: `Notification destination '${body.name}' not found` }, 404);
}
notifications = [destination];
} else {
notifications = await notificationsService.listDestinations();
}
return c.json({ notificationDestinations: await exportEntities(notifications, params) });
} catch (err) {
logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500);
}
})
.get("/export/backup-schedules", async (c) => {
.post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => {
try {
const params = parseExportParams(c);
const body = c.req.valid("json") as BackupScheduleExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
// Get all related data for name resolution
const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([
db.select().from(volumesTable),
db.select().from(repositoriesTable),
db.select().from(notificationDestinationsTable),
volumeService.listVolumes(),
repositoriesService.listRepositories(),
notificationsService.listDestinations(),
db.select().from(backupScheduleNotificationsTable),
]);
const result = await fetchBackupSchedules({ id: c.req.query("id") });
if ("error" in result) return c.json({ error: result.error }, result.status);
let schedules;
if (body.id !== undefined) {
try {
const schedule = await backupsService.getSchedule(body.id);
schedules = [schedule];
} catch {
return c.json({ error: `Backup schedule with ID '${body.id}' not found` }, 404);
}
} else {
schedules = await backupsService.listSchedules();
}
const volumeMap = new Map<number, string>(volumes.map((v) => [v.id, v.name]));
const repoMap = new Map<string, string>(repositories.map((r) => [r.id, r.name]));
const notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
const backupSchedules = transformBackupSchedules(
result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params
schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params
);
return c.json({ backupSchedules });

View file

@ -0,0 +1,289 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'");
const baseExportBodySchema = type({
/** Include database IDs in export (default: true) */
"includeIds?": "boolean",
/** Include createdAt/updatedAt timestamps (default: true) */
"includeTimestamps?": "boolean",
/** Include runtime state like status, health checks (default: false) */
"includeRuntimeState?": "boolean",
/** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */
"secretsMode?": secretsModeSchema,
/** Password required for authentication */
password: "string",
});
export const fullExportBodySchema = baseExportBodySchema.and(
type({
/** Include the recovery key (requires password) */
"includeRecoveryKey?": "boolean",
/** Include the admin password hash */
"includePasswordHash?": "boolean",
})
);
export const entityExportBodySchema = baseExportBodySchema.and(
type({
/** Filter by ID */
"id?": "string | number",
/** Filter by name */
"name?": "string",
})
);
export const backupScheduleExportBodySchema = baseExportBodySchema.and(
type({
/** Filter by ID */
"id?": "number",
})
);
export type FullExportBody = typeof fullExportBodySchema.infer;
export type EntityExportBody = typeof entityExportBodySchema.infer;
export type BackupScheduleExportBody = typeof backupScheduleExportBodySchema.infer;
export type SecretsMode = typeof secretsModeSchema.infer;
const exportResponseSchema = type({
"version?": "number",
"exportedAt?": "string",
"volumes?": "unknown[]",
"repositories?": "unknown[]",
"backupSchedules?": "unknown[]",
"notificationDestinations?": "unknown[]",
"admin?": type({
username: "string",
"passwordHash?": "string",
"recoveryKey?": "string",
}).or("null"),
});
const volumesExportResponseSchema = type({
volumes: "unknown[]",
});
const repositoriesExportResponseSchema = type({
repositories: "unknown[]",
});
const notificationsExportResponseSchema = type({
notificationDestinations: "unknown[]",
});
const backupSchedulesExportResponseSchema = type({
backupSchedules: "unknown[]",
});
const errorResponseSchema = type({
error: "string",
});
export const fullExportDto = describeRoute({
description: "Export full configuration including all volumes, repositories, backup schedules, and notifications",
operationId: "exportFullConfig",
tags: ["Config Export"],
responses: {
200: {
description: "Full configuration export",
content: {
"application/json": {
schema: resolver(exportResponseSchema),
},
},
},
401: {
description: "Password required for sensitive export options",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});
export const volumesExportDto = describeRoute({
description: "Export volumes configuration",
operationId: "exportVolumes",
tags: ["Config Export"],
responses: {
200: {
description: "Volumes configuration export",
content: {
"application/json": {
schema: resolver(volumesExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Volume not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});
export const repositoriesExportDto = describeRoute({
description: "Export repositories configuration",
operationId: "exportRepositories",
tags: ["Config Export"],
responses: {
200: {
description: "Repositories configuration export",
content: {
"application/json": {
schema: resolver(repositoriesExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
401: {
description: "Password required for sensitive export options",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Repository not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});
export const notificationsExportDto = describeRoute({
description: "Export notification destinations configuration",
operationId: "exportNotificationDestinations",
tags: ["Config Export"],
responses: {
200: {
description: "Notification destinations configuration export",
content: {
"application/json": {
schema: resolver(notificationsExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
401: {
description: "Password required for sensitive export options",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Notification destination not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});
export const backupSchedulesExportDto = describeRoute({
description: "Export backup schedules configuration",
operationId: "exportBackupSchedules",
tags: ["Config Export"],
responses: {
200: {
description: "Backup schedules configuration export",
content: {
"application/json": {
schema: resolver(backupSchedulesExportResponseSchema),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
404: {
description: "Backup schedule not found",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});