refactor: put export endpoint in system controller

This commit is contained in:
Nicolas Meienberger
2026-01-04 14:26:33 +01:00
parent 081a10afeb
commit 3f550678db
9 changed files with 198 additions and 247 deletions

View File

@@ -238,26 +238,6 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Exporting configuration
Zerobyte allows you to export your configuration for backup, migration, or documentation purposes.
To export, click the "Export" button in Settings. A dialog will appear with options to:
- **Include metadata** - Include IDs, timestamps, and runtime state of entities
- **Secrets handling**:
- **Exclude** - Remove sensitive fields like passwords and API keys
- **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import)
- **Decrypt** - Export secrets as plaintext (use with caution)
- **Include recovery key** - Include the master encryption key for all repositories
- **Include password hash** - Include the hashed user passwords (enables future import workflows)
Export requires password verification for security. You must enter your password to confirm your identity before any configuration can be exported.
Export is downloaded as JSON file that can be used for reference or future import functionality.
> **Sensitive data handling**: Some sensitive data from earlier versions may not be encrypted in the database. Additionally, nested configuration objects within config fields are exported as-is and not processed separately. Review exported data carefully before sharing, especially when using the "Decrypt" secrets option.
## Third-Party Software
This project includes the following third-party software components:

View File

@@ -430,7 +430,7 @@ 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>) => (options?.client ?? client).post<ExportFullConfigResponses, ExportFullConfigErrors, ThrowOnError>({
url: '/api/v1/config/export',
url: '/api/v1/system/export',
...options,
headers: {
'Content-Type': 'application/json',

View File

@@ -3345,7 +3345,7 @@ export type ExportFullConfigData = {
};
path?: never;
query?: never;
url: '/api/v1/config/export';
url: '/api/v1/system/export';
};
export type ExportFullConfigErrors = {

View File

@@ -12,7 +12,6 @@ import { volumeController } from "./modules/volumes/volume.controller";
import { backupScheduleController } from "./modules/backups/backups.controller";
import { eventsController } from "./modules/events/events.controller";
import { notificationsController } from "./modules/notifications/notifications.controller";
import { configExportController } from "./modules/lifecycle/config-export.controller";
import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger";
import { config } from "./core/config";
@@ -61,8 +60,7 @@ export const createApp = () => {
.route("/api/v1/backups", backupScheduleController)
.route("/api/v1/notifications", notificationsController)
.route("/api/v1/system", systemController)
.route("/api/v1/events", eventsController)
.route("/api/v1/config", configExportController);
.route("/api/v1/events", eventsController);
app.get("/api/v1/openapi.json", generalDescriptor(app));
app.get("/api/v1/docs", requireAuth, scalarDescriptor);

View File

@@ -1,155 +0,0 @@
import { validator } from "hono-openapi";
import { Hono } from "hono";
import type { Context } from "hono";
import {
backupScheduleNotificationsTable,
backupScheduleMirrorsTable,
usersTable,
type BackupSchedule,
type BackupScheduleNotification,
type BackupScheduleMirror,
volumesTable,
repositoriesTable,
backupSchedulesTable,
notificationDestinationsTable,
} from "../../db/schema";
import { db } from "../../db/db";
import { logger } from "../../utils/logger";
import { authService } from "../auth/auth.service";
import { fullExportBodySchema, fullExportDto, type FullExportBody } from "./config-export.dto";
import { requireAuth } from "../auth/auth.middleware";
import { toMessage } from "~/server/utils/errors";
const METADATA_KEYS = {
timestamps: [
"createdAt",
"updatedAt",
"lastBackupAt",
"nextBackupAt",
"lastHealthCheck",
"lastChecked",
"lastCopyAt",
],
runtimeState: [
"status",
"lastError",
"lastBackupStatus",
"lastBackupError",
"hasDownloadedResticPassword",
"lastCopyStatus",
"lastCopyError",
"sortOrder",
],
};
const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
if (includeMetadata) {
return obj;
}
const result = { ...obj };
for (const key of ALL_METADATA_KEYS) {
delete result[key as keyof T];
}
return result;
}
async function verifyExportPassword(
c: Context,
password: string,
): Promise<{ valid: true; userId: number } | { valid: false; error: string }> {
const user = c.get("user");
if (!user) {
return { valid: false, error: "Not authenticated" };
}
const isValid = await authService.verifyPassword(user.id, password);
if (!isValid) {
return { valid: false, error: "Incorrect password" };
}
return { valid: true, userId: user.id };
}
async function exportEntity(entity: Record<string, unknown>, params: FullExportBody) {
return filterMetadataOut(entity, params.includeMetadata);
}
async function exportEntities<T extends Record<string, unknown>>(entities: T[], params: FullExportBody) {
return Promise.all(entities.map((e) => exportEntity(e, params)));
}
const transformBackupSchedules = (
schedules: BackupSchedule[],
scheduleNotifications: BackupScheduleNotification[],
scheduleMirrors: BackupScheduleMirror[],
params: FullExportBody,
) => {
return schedules.map((schedule) => {
const assignments = scheduleNotifications
.filter((sn) => sn.scheduleId === schedule.id)
.map((sn) => filterMetadataOut(sn, params.includeMetadata));
const mirrors = scheduleMirrors
.filter((sm) => sm.scheduleId === schedule.id)
.map((sm) => filterMetadataOut(sm, params.includeMetadata));
return {
...filterMetadataOut(schedule, params.includeMetadata),
notifications: assignments,
mirrors,
};
});
};
export const configExportController = new Hono()
.use(requireAuth)
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
try {
const params = c.req.valid("json");
const verification = await verifyExportPassword(c, params.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] =
await Promise.all([
db.select().from(volumesTable),
db.select().from(repositoriesTable),
db.select().from(backupSchedulesTable),
db.select().from(notificationDestinationsTable),
db.select().from(backupScheduleNotificationsTable),
db.select().from(backupScheduleMirrorsTable),
db.select().from(usersTable),
]);
const backupSchedules = transformBackupSchedules(
backupSchedulesRaw,
scheduleNotifications,
scheduleMirrors,
params,
);
const [exportVolumes, exportRepositories, exportNotifications, exportUsers] = await Promise.all([
exportEntities(volumes, params),
exportEntities(repositories, params),
exportEntities(notifications, params),
exportEntities(users, params),
]);
return c.json({
version: 1,
exportedAt: new Date().toISOString(),
volumes: exportVolumes,
repositories: exportRepositories,
backupSchedules,
notificationDestinations: exportNotifications,
users: exportUsers,
});
} catch (err) {
logger.error(`Config export failed: ${toMessage(err)}`);
return c.json({ error: toMessage(err) }, 500);
}
});

View File

@@ -1,65 +0,0 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
export const fullExportBodySchema = type({
includeMetadata: "boolean = false",
password: "string",
});
export type FullExportBody = typeof fullExportBodySchema.infer;
const exportResponseSchema = type({
version: "number",
exportedAt: "string?",
recoveryKey: "string?",
volumes: "unknown[]?",
repositories: "unknown[]?",
backupSchedules: "unknown[]?",
notificationDestinations: "unknown[]?",
users: type({
id: "number?",
username: "string",
passwordHash: "string?",
createdAt: "number?",
updatedAt: "number?",
hasDownloadedResticPassword: "boolean?",
})
.array()
.optional(),
});
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 export or authentication failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});

View File

@@ -3,6 +3,8 @@ import { validator } from "hono-openapi";
import {
downloadResticPasswordBodySchema,
downloadResticPasswordDto,
fullExportBodySchema,
fullExportDto,
getUpdatesDto,
systemInfoDto,
type SystemInfoDto,
@@ -61,4 +63,20 @@ export const systemController = new Hono()
return c.json({ message: "Failed to read Restic password file" }, 500);
}
},
);
)
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
const user = c.get("user");
const body = c.req.valid("json");
const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id));
if (!dbUser) {
return c.json({ message: "User not found" }, 401);
}
const isValid = await Bun.password.verify(body.password, dbUser.passwordHash);
if (!isValid) {
return c.json({ message: "Incorrect password" }, 401);
}
});

View File

@@ -79,3 +79,66 @@ export const downloadResticPasswordDto = describeRoute({
},
},
});
export const fullExportBodySchema = type({
includeMetadata: "boolean = false",
password: "string",
});
export type FullExportBody = typeof fullExportBodySchema.infer;
const exportResponseSchema = type({
version: "number",
exportedAt: "string?",
recoveryKey: "string?",
volumes: "unknown[]?",
repositories: "unknown[]?",
backupSchedules: "unknown[]?",
notificationDestinations: "unknown[]?",
users: type({
id: "number?",
username: "string",
passwordHash: "string?",
createdAt: "number?",
updatedAt: "number?",
hasDownloadedResticPassword: "boolean?",
})
.array()
.optional(),
});
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 export or authentication failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});

View File

@@ -1,9 +1,22 @@
import { getCapabilities } from "../../core/capabilities";
import { config } from "../../core/config";
import type { UpdateInfoDto } from "./system.dto";
import type { FullExportBody, UpdateInfoDto } from "./system.dto";
import semver from "semver";
import { cache } from "../../utils/cache";
import { logger } from "~/server/utils/logger";
import { db } from "~/server/db/db";
import {
backupScheduleMirrorsTable,
backupScheduleNotificationsTable,
backupSchedulesTable,
notificationDestinationsTable,
repositoriesTable,
usersTable,
volumesTable,
type BackupScheduleMirror,
type BackupScheduleNotification,
type BackupSchedule,
} from "~/server/db/schema";
const CACHE_TTL = 60 * 60;
@@ -90,7 +103,106 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
}
};
const METADATA_KEYS = {
timestamps: [
"createdAt",
"updatedAt",
"lastBackupAt",
"nextBackupAt",
"lastHealthCheck",
"lastChecked",
"lastCopyAt",
],
runtimeState: [
"status",
"lastError",
"lastBackupStatus",
"lastBackupError",
"hasDownloadedResticPassword",
"lastCopyStatus",
"lastCopyError",
"sortOrder",
],
};
const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
if (includeMetadata) {
return obj;
}
const result = { ...obj };
for (const key of ALL_METADATA_KEYS) {
delete result[key as keyof T];
}
return result;
}
async function exportEntity(entity: Record<string, unknown>, params: FullExportBody) {
return filterMetadataOut(entity, params.includeMetadata);
}
async function exportEntities<T extends Record<string, unknown>>(entities: T[], params: FullExportBody) {
return Promise.all(entities.map((e) => exportEntity(e, params)));
}
const transformBackupSchedules = (
schedules: BackupSchedule[],
scheduleNotifications: BackupScheduleNotification[],
scheduleMirrors: BackupScheduleMirror[],
params: FullExportBody,
) => {
return schedules.map((schedule) => {
const assignments = scheduleNotifications
.filter((sn) => sn.scheduleId === schedule.id)
.map((sn) => filterMetadataOut(sn, params.includeMetadata));
const mirrors = scheduleMirrors
.filter((sm) => sm.scheduleId === schedule.id)
.map((sm) => filterMetadataOut(sm, params.includeMetadata));
return {
...filterMetadataOut(schedule, params.includeMetadata),
notifications: assignments,
mirrors,
};
});
};
const exportConfig = async (params: FullExportBody) => {
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] =
await Promise.all([
db.select().from(volumesTable),
db.select().from(repositoriesTable),
db.select().from(backupSchedulesTable),
db.select().from(notificationDestinationsTable),
db.select().from(backupScheduleNotificationsTable),
db.select().from(backupScheduleMirrorsTable),
db.select().from(usersTable),
]);
const backupSchedules = transformBackupSchedules(backupSchedulesRaw, scheduleNotifications, scheduleMirrors, params);
const [exportVolumes, exportRepositories, exportNotifications, exportUsers] = await Promise.all([
exportEntities(volumes, params),
exportEntities(repositories, params),
exportEntities(notifications, params),
exportEntities(users, params),
]);
return {
version: 1,
exportedAt: new Date().toISOString(),
volumes: exportVolumes,
repositories: exportRepositories,
backupSchedules,
notificationDestinations: exportNotifications,
users: exportUsers,
};
};
export const systemService = {
getSystemInfo,
getUpdates,
exportConfig,
};