mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-04-20 23:09:16 -04:00
refactor: put export endpoint in system controller
This commit is contained in:
20
README.md
20
README.md
@@ -238,26 +238,6 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3345,7 +3345,7 @@ export type ExportFullConfigData = {
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/config/export';
|
||||
url: '/api/v1/system/export';
|
||||
};
|
||||
|
||||
export type ExportFullConfigErrors = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user