refactor: unify backend and frontend servers (#3)

* refactor: unify backend and frontend servers

* refactor: correct paths for openapi & drizzle

* refactor: move api-client to client

* fix: drizzle paths

* chore: fix linting issues

* fix: form reset issue
This commit is contained in:
Nico
2025-11-13 20:11:46 +01:00
committed by GitHub
parent 8d7e50508d
commit 95a0d44b45
240 changed files with 5171 additions and 5875 deletions

View File

@@ -0,0 +1,141 @@
import { validator } from "hono-openapi";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import {
changePasswordBodySchema,
changePasswordDto,
getMeDto,
getStatusDto,
loginBodySchema,
loginDto,
logoutDto,
registerBodySchema,
registerDto,
type ChangePasswordDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
} from "./auth.dto";
import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
};
export const authController = new Hono()
.post("/register", registerDto, validator("json", registerBodySchema), async (c) => {
const body = c.req.valid("json");
try {
const { user, sessionId } = await authService.register(body.username, body.password);
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
return c.json<RegisterDto>(
{
success: true,
message: "User registered successfully",
user: {
id: user.id,
username: user.username,
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
},
},
201,
);
} catch (error) {
return c.json<RegisterDto>({ success: false, message: toMessage(error) }, 400);
}
})
.post("/login", loginDto, validator("json", loginBodySchema), async (c) => {
const body = c.req.valid("json");
try {
const { sessionId, user, expiresAt } = await authService.login(body.username, body.password);
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: new Date(expiresAt),
});
return c.json<LoginDto>({
success: true,
message: "Login successful",
user: {
id: user.id,
username: user.username,
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
},
});
} catch (error) {
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
}
})
.post("/logout", logoutDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
await authService.logout(sessionId);
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
return c.json<LogoutDto>({ success: true });
})
.get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json<GetMeDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json({ message: "Not authenticated" }, 401);
}
return c.json<GetMeDto>({
success: true,
user: session.user,
message: "Authenticated",
});
})
.get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers();
return c.json<GetStatusDto>({ hasUsers });
})
.post("/change-password", changePasswordDto, validator("json", changePasswordBodySchema), async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
}
const body = c.req.valid("json");
try {
await authService.changePassword(session.user.id, body.currentPassword, body.newPassword);
return c.json<ChangePasswordDto>({ success: true, message: "Password changed successfully" });
} catch (error) {
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
}
});

View File

@@ -0,0 +1,153 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
// Validation schemas
export const loginBodySchema = type({
username: "string>0",
password: "string>7",
});
export const registerBodySchema = type({
username: "string>2",
password: "string>7",
});
const loginResponseSchema = type({
message: "string",
success: "boolean",
user: type({
id: "number",
username: "string",
hasDownloadedResticPassword: "boolean",
}).optional(),
});
export const loginDto = describeRoute({
description: "Login with username and password",
operationId: "login",
tags: ["Auth"],
responses: {
200: {
description: "Login successful",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
},
});
export type LoginDto = typeof loginResponseSchema.infer;
export const registerDto = describeRoute({
description: "Register a new user",
operationId: "register",
tags: ["Auth"],
responses: {
201: {
description: "User created successfully",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
},
});
export type RegisterDto = typeof loginResponseSchema.infer;
const logoutResponseSchema = type({
success: "boolean",
});
export const logoutDto = describeRoute({
description: "Logout current user",
operationId: "logout",
tags: ["Auth"],
responses: {
200: {
description: "Logout successful",
content: {
"application/json": {
schema: resolver(logoutResponseSchema),
},
},
},
},
});
export type LogoutDto = typeof logoutResponseSchema.infer;
export const getMeDto = describeRoute({
description: "Get current authenticated user",
operationId: "getMe",
tags: ["Auth"],
responses: {
200: {
description: "Current user information",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
},
});
export type GetMeDto = typeof loginResponseSchema.infer;
const statusResponseSchema = type({
hasUsers: "boolean",
});
export const getStatusDto = describeRoute({
description: "Get authentication system status",
operationId: "getStatus",
tags: ["Auth"],
responses: {
200: {
description: "Authentication system status",
content: {
"application/json": {
schema: resolver(statusResponseSchema),
},
},
},
},
});
export type GetStatusDto = typeof statusResponseSchema.infer;
export const changePasswordBodySchema = type({
currentPassword: "string>0",
newPassword: "string>7",
});
const changePasswordResponseSchema = type({
success: "boolean",
message: "string",
});
export const changePasswordDto = describeRoute({
description: "Change current user password",
operationId: "changePassword",
tags: ["Auth"],
responses: {
200: {
description: "Password changed successfully",
content: {
"application/json": {
schema: resolver(changePasswordResponseSchema),
},
},
},
},
});
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer;
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;

View File

@@ -0,0 +1,64 @@
import { deleteCookie, getCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { authService } from "./auth.service";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
};
declare module "hono" {
interface ContextVariableMap {
user: {
id: number;
username: string;
hasDownloadedResticPassword: boolean;
};
}
}
/**
* Middleware to require authentication
* Verifies the session cookie and attaches user to context
*/
export const requireAuth = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ message: "Authentication required" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json({ message: "Invalid or expired session" }, 401);
}
c.set("user", session.user);
await next();
});
/**
* Middleware to optionally attach user if authenticated
* Does not block the request if not authenticated
*/
export const optionalAuth = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
const session = await authService.verifySession(sessionId);
if (session) {
c.set("user", session.user);
} else {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
}
await next();
});

View File

@@ -0,0 +1,179 @@
import { eq, lt } from "drizzle-orm";
import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
export class AuthService {
/**
* Register a new user with username and password
*/
async register(username: string, password: string) {
const [existingUser] = await db.select().from(usersTable);
if (existingUser) {
throw new Error("Admin user already exists");
}
const passwordHash = await Bun.password.hash(password, {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2,
});
const [user] = await db.insert(usersTable).values({ username, passwordHash }).returning();
if (!user) {
throw new Error("User registration failed");
}
logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
return {
user: {
id: user.id,
username: user.username,
createdAt: user.createdAt,
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
},
sessionId,
};
}
/**
* Login user with username and password
*/
async login(username: string, password: string) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
if (!user) {
throw new Error("Invalid credentials");
}
const isValid = await Bun.password.verify(password, user.passwordHash);
if (!isValid) {
throw new Error("Invalid credentials");
}
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
logger.info(`User logged in: ${username}`);
return {
sessionId,
user: {
id: user.id,
username: user.username,
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
},
expiresAt,
};
}
/**
* Logout user by deleting their session
*/
async logout(sessionId: string) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
logger.info(`User logged out: session ${sessionId}`);
}
/**
* Verify a session and return the associated user
*/
async verifySession(sessionId: string) {
const [session] = await db
.select({
session: sessionsTable,
user: usersTable,
})
.from(sessionsTable)
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id))
.where(eq(sessionsTable.id, sessionId));
if (!session) {
return null;
}
if (session.session.expiresAt < Date.now()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null;
}
return {
user: {
id: session.user.id,
username: session.user.username,
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
},
session: {
id: session.session.id,
expiresAt: session.session.expiresAt,
},
};
}
/**
* Clean up expired sessions
*/
async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning();
if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`);
}
}
/**
* Check if any users exist in the system
*/
async hasUsers(): Promise<boolean> {
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
return !!user;
}
/**
* Change password for a user
*/
async changePassword(userId: number, currentPassword: string, newPassword: string) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
if (!user) {
throw new Error("User not found");
}
const isValid = await Bun.password.verify(currentPassword, user.passwordHash);
if (!isValid) {
throw new Error("Current password is incorrect");
}
const newPasswordHash = await Bun.password.hash(newPassword, {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2,
});
await db.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, userId));
logger.info(`Password changed for user: ${user.username}`);
}
}
export const authService = new AuthService();

View File

@@ -0,0 +1,37 @@
import type { BackendStatus } from "~/schemas/volumes";
import type { Volume } from "../../db/schema";
import { getVolumePath } from "../volumes/helpers";
import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend";
import { makeSmbBackend } from "./smb/smb-backend";
import { makeWebdavBackend } from "./webdav/webdav-backend";
type OperationResult = {
error?: string;
status: BackendStatus;
};
export type VolumeBackend = {
mount: () => Promise<OperationResult>;
unmount: () => Promise<OperationResult>;
checkHealth: () => Promise<OperationResult>;
};
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
const path = getVolumePath(volume);
switch (volume.config.backend) {
case "nfs": {
return makeNfsBackend(volume.config, path);
}
case "smb": {
return makeSmbBackend(volume.config, path);
}
case "directory": {
return makeDirectoryBackend(volume.config, path);
}
case "webdav": {
return makeWebdavBackend(volume.config, path);
}
}
};

View File

@@ -0,0 +1,59 @@
import * as fs from "node:fs/promises";
import * as npath from "node:path";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import type { VolumeBackend } from "../backend";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, _volumePath: string) => {
if (config.backend !== "directory") {
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
}
logger.info("Mounting directory volume from:", config.path);
try {
await fs.access(config.path);
const stats = await fs.stat(config.path);
if (!stats.isDirectory()) {
return { status: BACKEND_STATUS.error, error: "Path is not a directory" };
}
return { status: BACKEND_STATUS.mounted };
} catch (error) {
logger.error("Failed to mount directory volume:", error);
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const unmount = async () => {
logger.info("Cannot unmount directory volume.");
return { status: BACKEND_STATUS.unmounted };
};
const checkHealth = async (config: BackendConfig) => {
if (config.backend !== "directory") {
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
}
try {
await fs.access(config.path);
// Try to create a temporary file to ensure write access
const tempFilePath = npath.join(config.path, `.healthcheck-${Date.now()}`);
await fs.writeFile(tempFilePath, "healthcheck");
await fs.unlink(tempFilePath);
return { status: BACKEND_STATUS.mounted };
} catch (error) {
logger.error("Directory health check failed:", error);
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
mount: () => mount(config, volumePath),
unmount,
checkHealth: () => checkHealth(config),
});

View File

@@ -0,0 +1,121 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting volume ${path}...`);
if (config.backend !== "nfs") {
logger.error("Provided config is not for NFS backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for NFS backend" };
}
if (os.platform() !== "linux") {
logger.error("NFS mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true });
const source = `${config.server}:${config.exportPath}`;
const options = [`vers=${config.version}`, `port=${config.port}`];
if (config.readOnly) {
options.push("ro");
}
const args = ["-t", "nfs", "-o", options.join(","), source, path];
logger.debug(`Mounting volume ${path}...`);
logger.info(`Executing mount: mount ${args.join(" ")}`);
await executeMount(args);
logger.info(`NFS volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "NFS mount");
} catch (err) {
logger.error("Error mounting NFS volume", { error: toMessage(err) });
return { status: BACKEND_STATUS.error, error: toMessage(err) };
}
};
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("NFS unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." };
}
const run = async () => {
try {
await fs.access(path);
} catch {
logger.warn(`Path ${path} does not exist. Skipping unmount.`);
return { status: BACKEND_STATUS.unmounted };
}
await executeUnmount(path);
await fs.rmdir(path);
logger.info(`NFS volume at ${path} unmounted successfully.`);
return { status: BACKEND_STATUS.unmounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "NFS unmount");
} catch (err) {
logger.error("Error unmounting NFS volume", { path, error: toMessage(err) });
return { status: BACKEND_STATUS.error, error: toMessage(err) };
}
};
const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => {
logger.debug(`Checking health of NFS volume at ${path}...`);
await fs.access(path);
const mount = await getMountForPath(path);
if (!mount || !mount.fstype.startsWith("nfs")) {
throw new Error(`Path ${path} is not mounted as NFS.`);
}
if (!readOnly) {
await createTestFile(path);
}
logger.debug(`NFS volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "NFS health check");
} catch (error) {
logger.error("NFS volume health check failed:", toMessage(error));
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -0,0 +1,134 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting SMB volume ${path}...`);
if (config.backend !== "smb") {
logger.error("Provided config is not for SMB backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for SMB backend" };
}
if (os.platform() !== "linux") {
logger.error("SMB mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true });
const source = `//${config.server}/${config.share}`;
const options = [
`user=${config.username}`,
`pass=${config.password}`,
`vers=${config.vers}`,
`port=${config.port}`,
"uid=1000",
"gid=1000",
];
if (config.domain) {
options.push(`domain=${config.domain}`);
}
if (config.readOnly) {
options.push("ro");
}
const args = ["-t", "cifs", "-o", options.join(","), source, path];
logger.debug(`Mounting SMB volume ${path}...`);
logger.info(`Executing mount: mount ${args.join(" ")}`);
await executeMount(args);
logger.info(`SMB volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "SMB mount");
} catch (error) {
logger.error("Error mounting SMB volume", { error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("SMB unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "SMB unmounting is only supported on Linux hosts." };
}
const run = async () => {
try {
await fs.access(path);
} catch {
logger.warn(`Path ${path} does not exist. Skipping unmount.`);
return { status: BACKEND_STATUS.unmounted };
}
await executeUnmount(path);
await fs.rmdir(path);
logger.info(`SMB volume at ${path} unmounted successfully.`);
return { status: BACKEND_STATUS.unmounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "SMB unmount");
} catch (error) {
logger.error("Error unmounting SMB volume", { path, error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => {
logger.debug(`Checking health of SMB volume at ${path}...`);
await fs.access(path);
const mount = await getMountForPath(path);
if (!mount || mount.fstype !== "cifs") {
throw new Error(`Path ${path} is not mounted as CIFS/SMB.`);
}
if (!readOnly) {
await createTestFile(path);
}
logger.debug(`SMB volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "SMB health check");
} catch (error) {
logger.error("SMB volume health check failed:", toMessage(error));
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -0,0 +1,47 @@
import * as fs from "node:fs/promises";
import * as npath from "node:path";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { $ } from "bun";
export const executeMount = async (args: string[]): Promise<void> => {
let stderr: string | undefined;
const result = await $`mount ${args}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
};
export const executeUnmount = async (path: string): Promise<void> => {
let stderr: string | undefined;
const result = await $`umount -l -f ${path}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
};
export const createTestFile = async (path: string): Promise<void> => {
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
await fs.writeFile(testFilePath, "healthcheck");
const files = await fs.readdir(path);
await Promise.all(
files.map(async (file) => {
if (file.startsWith(".healthcheck-")) {
const filePath = npath.join(path, file);
try {
await fs.unlink(filePath);
} catch (err) {
logger.warn(`Failed to stat or unlink file ${filePath}: ${toMessage(err)}`);
}
}
}),
);
};

View File

@@ -0,0 +1,168 @@
import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const execFile = promisify(execFileCb);
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting WebDAV volume ${path}...`);
if (config.backend !== "webdav") {
logger.error("Provided config is not for WebDAV backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for WebDAV backend" };
}
if (os.platform() !== "linux") {
logger.error("WebDAV mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true }).catch((err) => {
logger.warn(`Failed to create directory ${path}: ${err.message}`);
});
const protocol = config.ssl ? "https" : "http";
const defaultPort = config.ssl ? 443 : 80;
const port = config.port !== defaultPort ? `:${config.port}` : "";
const source = `${protocol}://${config.server}${port}${config.path}`;
const options = config.readOnly
? ["uid=1000", "gid=1000", "file_mode=0444", "dir_mode=0555", "ro"]
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) {
const secretsFile = "/etc/davfs2/secrets";
const secretsContent = `${source} ${config.username} ${config.password}\n`;
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });
}
logger.debug(`Mounting WebDAV volume ${path}...`);
const args = ["-t", "davfs", source, path];
await executeMount(args);
const { stderr } = await execFile("mount", ["-t", "davfs", "-o", options.join(","), source, path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
logger.info(`WebDAV volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "WebDAV mount");
} catch (error) {
const errorMsg = toMessage(error);
if (errorMsg.includes("already mounted")) {
return { status: BACKEND_STATUS.mounted };
}
logger.error("Error mounting WebDAV volume", { error: errorMsg });
if (errorMsg.includes("option") && errorMsg.includes("requires argument")) {
return {
status: BACKEND_STATUS.error,
error: "Invalid mount options. Please check your WebDAV server configuration.",
};
} else if (errorMsg.includes("connection refused") || errorMsg.includes("Connection refused")) {
return {
status: BACKEND_STATUS.error,
error: "Cannot connect to WebDAV server. Please check the server address and port.",
};
} else if (errorMsg.includes("unauthorized") || errorMsg.includes("Unauthorized")) {
return {
status: BACKEND_STATUS.error,
error: "Authentication failed. Please check your username and password.",
};
}
return { status: BACKEND_STATUS.error, error: errorMsg };
}
};
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("WebDAV unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "WebDAV unmounting is only supported on Linux hosts." };
}
const run = async () => {
try {
await fs.access(path);
} catch (e) {
logger.warn(`Path ${path} does not exist. Skipping unmount.`, e);
return { status: BACKEND_STATUS.unmounted };
}
await executeUnmount(path);
await fs.rmdir(path);
logger.info(`WebDAV volume at ${path} unmounted successfully.`);
return { status: BACKEND_STATUS.unmounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "WebDAV unmount");
} catch (error) {
logger.error("Error unmounting WebDAV volume", { path, error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => {
logger.debug(`Checking health of WebDAV volume at ${path}...`);
await fs.access(path);
const mount = await getMountForPath(path);
if (!mount || mount.fstype !== "fuse") {
throw new Error(`Path ${path} is not mounted as WebDAV.`);
}
if (!readOnly) {
await createTestFile(path);
}
logger.debug(`WebDAV volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "WebDAV health check");
} catch (error) {
logger.error("WebDAV volume health check failed:", toMessage(error));
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -0,0 +1,81 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createBackupScheduleBody,
createBackupScheduleDto,
deleteBackupScheduleDto,
getBackupScheduleDto,
getBackupScheduleForVolumeDto,
listBackupSchedulesDto,
runBackupNowDto,
stopBackupDto,
updateBackupScheduleDto,
updateBackupScheduleBody,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
type GetBackupScheduleForVolumeResponseDto,
type ListBackupSchedulesResponseDto,
type RunBackupNowDto,
type StopBackupDto,
type UpdateBackupScheduleDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
export const backupScheduleController = new Hono()
.get("/", listBackupSchedulesDto, async (c) => {
const schedules = await backupsService.listSchedules();
return c.json<ListBackupSchedulesResponseDto>(schedules, 200);
})
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
const schedule = await backupsService.getSchedule(Number(scheduleId));
return c.json<GetBackupScheduleDto>(schedule, 200);
})
.get("/volume/:volumeId", getBackupScheduleForVolumeDto, async (c) => {
const volumeId = c.req.param("volumeId");
const schedule = await backupsService.getScheduleForVolume(Number(volumeId));
return c.json<GetBackupScheduleForVolumeResponseDto>(schedule, 200);
})
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
const body = c.req.valid("json");
const schedule = await backupsService.createSchedule(body);
return c.json<CreateBackupScheduleDto>(schedule, 201);
})
.patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
const scheduleId = c.req.param("scheduleId");
const body = c.req.valid("json");
const schedule = await backupsService.updateSchedule(Number(scheduleId), body);
return c.json<UpdateBackupScheduleDto>(schedule, 200);
})
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
await backupsService.deleteSchedule(Number(scheduleId));
return c.json<DeleteBackupScheduleDto>({ success: true }, 200);
})
.post("/:scheduleId/run", runBackupNowDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
backupsService.executeBackup(Number(scheduleId), true).catch((error) => {
console.error("Backup execution failed:", error);
});
return c.json<RunBackupNowDto>({ success: true }, 200);
})
.post("/:scheduleId/stop", stopBackupDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
await backupsService.stopBackup(Number(scheduleId));
return c.json<StopBackupDto>({ success: true }, 200);
});

View File

@@ -0,0 +1,253 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
import { volumeSchema } from "../volumes/volume.dto";
import { repositorySchema } from "../repositories/repositories.dto";
const retentionPolicySchema = type({
keepLast: "number?",
keepHourly: "number?",
keepDaily: "number?",
keepWeekly: "number?",
keepMonthly: "number?",
keepYearly: "number?",
keepWithinDuration: "string?",
});
export type RetentionPolicy = typeof retentionPolicySchema.infer;
const backupScheduleSchema = type({
id: "number",
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.or("null"),
excludePatterns: "string[] | null",
includePatterns: "string[] | null",
lastBackupAt: "number | null",
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
lastBackupError: "string | null",
nextBackupAt: "number | null",
createdAt: "number",
updatedAt: "number",
}).and(
type({
volume: volumeSchema,
repository: repositorySchema,
}),
);
/**
* List all backup schedules
*/
export const listBackupSchedulesResponse = backupScheduleSchema.array();
export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer;
export const listBackupSchedulesDto = describeRoute({
description: "List all backup schedules",
tags: ["Backups"],
operationId: "listBackupSchedules",
responses: {
200: {
description: "List of backup schedules",
content: {
"application/json": {
schema: resolver(listBackupSchedulesResponse),
},
},
},
},
});
/**
* Get a single backup schedule
*/
export const getBackupScheduleResponse = backupScheduleSchema;
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
export const getBackupScheduleDto = describeRoute({
description: "Get a backup schedule by ID",
tags: ["Backups"],
operationId: "getBackupSchedule",
responses: {
200: {
description: "Backup schedule details",
content: {
"application/json": {
schema: resolver(getBackupScheduleResponse),
},
},
},
},
});
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.or("null");
export type GetBackupScheduleForVolumeResponseDto = typeof getBackupScheduleForVolumeResponse.infer;
export const getBackupScheduleForVolumeDto = describeRoute({
description: "Get a backup schedule for a specific volume",
tags: ["Backups"],
operationId: "getBackupScheduleForVolume",
responses: {
200: {
description: "Backup schedule details for the volume",
content: {
"application/json": {
schema: resolver(getBackupScheduleForVolumeResponse),
},
},
},
},
});
/**
* Create a new backup schedule
*/
export const createBackupScheduleBody = type({
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
export const createBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer;
export const createBackupScheduleDto = describeRoute({
description: "Create a new backup schedule for a volume",
operationId: "createBackupSchedule",
tags: ["Backups"],
responses: {
201: {
description: "Backup schedule created successfully",
content: {
"application/json": {
schema: resolver(createBackupScheduleResponse),
},
},
},
},
});
/**
* Update a backup schedule
*/
export const updateBackupScheduleBody = type({
repositoryId: "string",
enabled: "boolean?",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
export const updateBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer;
export const updateBackupScheduleDto = describeRoute({
description: "Update a backup schedule",
operationId: "updateBackupSchedule",
tags: ["Backups"],
responses: {
200: {
description: "Backup schedule updated successfully",
content: {
"application/json": {
schema: resolver(updateBackupScheduleResponse),
},
},
},
},
});
/**
* Delete a backup schedule
*/
export const deleteBackupScheduleResponse = type({
success: "boolean",
});
export type DeleteBackupScheduleDto = typeof deleteBackupScheduleResponse.infer;
export const deleteBackupScheduleDto = describeRoute({
description: "Delete a backup schedule",
operationId: "deleteBackupSchedule",
tags: ["Backups"],
responses: {
200: {
description: "Backup schedule deleted successfully",
content: {
"application/json": {
schema: resolver(deleteBackupScheduleResponse),
},
},
},
},
});
/**
* Run a backup immediately
*/
export const runBackupNowResponse = type({
success: "boolean",
});
export type RunBackupNowDto = typeof runBackupNowResponse.infer;
export const runBackupNowDto = describeRoute({
description: "Trigger a backup immediately for a schedule",
operationId: "runBackupNow",
tags: ["Backups"],
responses: {
200: {
description: "Backup started successfully",
content: {
"application/json": {
schema: resolver(runBackupNowResponse),
},
},
},
},
});
/**
* Stop a running backup
*/
export const stopBackupResponse = type({
success: "boolean",
});
export type StopBackupDto = typeof stopBackupResponse.infer;
export const stopBackupDto = describeRoute({
description: "Stop a backup that is currently in progress",
operationId: "stopBackup",
tags: ["Backups"],
responses: {
200: {
description: "Backup stopped successfully",
content: {
"application/json": {
schema: resolver(stopBackupResponse),
},
},
},
409: {
description: "No backup is currently running for this schedule",
},
},
});

View File

@@ -0,0 +1,353 @@
import { eq } from "drizzle-orm";
import cron from "node-cron";
import { CronExpressionParser } from "cron-parser";
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
import { db } from "../../db/db";
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
import { restic } from "../../utils/restic";
import { logger } from "../../utils/logger";
import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events";
const runningBackups = new Map<number, AbortController>();
const calculateNextRun = (cronExpression: string): number => {
try {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(),
tz: "UTC",
});
return interval.next().getTime();
} catch (error) {
logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`);
const fallback = new Date();
fallback.setMinutes(fallback.getMinutes() + 1);
return fallback.getTime();
}
};
const listSchedules = async () => {
const schedules = await db.query.backupSchedulesTable.findMany({
with: {
volume: true,
repository: true,
},
});
return schedules;
};
const getSchedule = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(volumesTable.id, scheduleId),
with: {
volume: true,
repository: true,
},
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
return schedule;
};
const createSchedule = async (data: CreateBackupScheduleBody) => {
if (!cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, data.volumeId),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, data.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const nextBackupAt = calculateNextRun(data.cronExpression);
const [newSchedule] = await db
.insert(backupSchedulesTable)
.values({
volumeId: data.volumeId,
repositoryId: data.repositoryId,
enabled: data.enabled,
cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [],
includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt,
})
.returning();
if (!newSchedule) {
throw new Error("Failed to create backup schedule");
}
return newSchedule;
};
const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
if (data.cronExpression && !cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, data.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const cronExpression = data.cronExpression ?? schedule.cronExpression;
const nextBackupAt = data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt;
const [updated] = await db
.update(backupSchedulesTable)
.set({ ...data, nextBackupAt, updatedAt: Date.now() })
.where(eq(backupSchedulesTable.id, scheduleId))
.returning();
if (!updated) {
throw new Error("Failed to update backup schedule");
}
return updated;
};
const deleteSchedule = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
await db.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId));
};
const executeBackup = async (scheduleId: number, manual = false) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
if (!schedule.enabled && !manual) {
logger.info(`Backup schedule ${scheduleId} is disabled. Skipping execution.`);
return;
}
if (schedule.lastBackupStatus === "in_progress") {
logger.info(`Backup schedule ${scheduleId} is already in progress. Skipping execution.`);
return;
}
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, schedule.volumeId),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, schedule.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
if (volume.status !== "mounted") {
throw new BadRequestError("Volume is not mounted");
}
logger.info(`Starting backup for volume ${volume.name} to repository ${repository.name}`);
serverEvents.emit("backup:started", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
});
await db
.update(backupSchedulesTable)
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
.where(eq(backupSchedulesTable.id, scheduleId));
const abortController = new AbortController();
runningBackups.set(scheduleId, abortController);
try {
const volumePath = getVolumePath(volume);
const backupOptions: {
exclude?: string[];
include?: string[];
tags?: string[];
signal?: AbortSignal;
} = {
tags: [schedule.id.toString()],
signal: abortController.signal,
};
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
backupOptions.exclude = schedule.excludePatterns;
}
if (schedule.includePatterns && schedule.includePatterns.length > 0) {
backupOptions.include = schedule.includePatterns;
}
await restic.backup(repository.config, volumePath, {
...backupOptions,
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
...progress,
});
},
});
if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
}
const nextBackupAt = calculateNextRun(schedule.cronExpression);
await db
.update(backupSchedulesTable)
.set({
lastBackupAt: Date.now(),
lastBackupStatus: "success",
lastBackupError: null,
nextBackupAt: nextBackupAt,
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
serverEvents.emit("backup:completed", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
status: "success",
});
} catch (error) {
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
await db
.update(backupSchedulesTable)
.set({
lastBackupAt: Date.now(),
lastBackupStatus: "error",
lastBackupError: toMessage(error),
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
serverEvents.emit("backup:completed", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
status: "error",
});
throw error;
} finally {
runningBackups.delete(scheduleId);
}
};
const getSchedulesToExecute = async () => {
const now = Date.now();
const schedules = await db.query.backupSchedulesTable.findMany({
where: eq(backupSchedulesTable.enabled, true),
});
const schedulesToRun: number[] = [];
for (const schedule of schedules) {
if (!schedule.nextBackupAt || schedule.nextBackupAt <= now) {
schedulesToRun.push(schedule.id);
}
}
return schedulesToRun;
};
const getScheduleForVolume = async (volumeId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.volumeId, volumeId),
with: { volume: true, repository: true },
});
return schedule ?? null;
};
const stopBackup = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
await db
.update(backupSchedulesTable)
.set({
lastBackupStatus: "error",
lastBackupError: "Backup was stopped by user",
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
const abortController = runningBackups.get(scheduleId);
if (!abortController) {
throw new ConflictError("No backup is currently running for this schedule");
}
logger.info(`Stopping backup for schedule ${scheduleId}`);
abortController.abort();
};
export const backupsService = {
listSchedules,
getSchedule,
createSchedule,
updateSchedule,
deleteSchedule,
executeBackup,
getSchedulesToExecute,
getScheduleForVolume,
stopBackup,
};

View File

@@ -0,0 +1,87 @@
import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service";
import { getVolumePath } from "../volumes/helpers";
export const driverController = new Hono()
.post("/VolumeDriver.Capabilities", (c) => {
return c.json({
Capabilities: {
Scope: "global",
},
});
})
.post("/Plugin.Activate", (c) => {
return c.json({
Implements: ["VolumeDriver"],
});
})
.post("/VolumeDriver.Create", (_) => {
throw new Error("Volume creation is not supported via the driver");
})
.post("/VolumeDriver.Remove", (c) => {
return c.json({
Err: "",
});
})
.post("/VolumeDriver.Mount", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const volumeName = body.Name.replace(/^im-/, "");
return c.json({
Mountpoint: getVolumePath(volumeName),
});
})
.post("/VolumeDriver.Unmount", (c) => {
return c.json({
Err: "",
});
})
.post("/VolumeDriver.Path", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
return c.json({
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Get", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
return c.json({
Volume: {
Name: `im-${volume.name}`,
Mountpoint: getVolumePath(volume),
Status: {},
},
Err: "",
});
})
.post("/VolumeDriver.List", async (c) => {
const volumes = await volumeService.listVolumes();
const res = volumes.map((volume) => ({
Name: `im-${volume.name}`,
Mountpoint: getVolumePath(volume),
Status: {},
}));
return c.json({
Volumes: res,
});
});

View File

@@ -0,0 +1,101 @@
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events";
export const eventsController = new Hono().get("/", (c) => {
logger.info("Client connected to SSE endpoint");
return streamSSE(c, async (stream) => {
await stream.writeSSE({
data: JSON.stringify({ type: "connected", timestamp: Date.now() }),
event: "connected",
});
const onBackupStarted = (data: { scheduleId: number; volumeName: string; repositoryName: string }) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "backup:started",
});
};
const onBackupProgress = (data: {
scheduleId: number;
volumeName: string;
repositoryName: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
current_files: string[];
}) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "backup:progress",
});
};
const onBackupCompleted = (data: {
scheduleId: number;
volumeName: string;
repositoryName: string;
status: "success" | "error" | "stopped";
}) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "backup:completed",
});
};
const onVolumeMounted = (data: { volumeName: string }) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "volume:mounted",
});
};
const onVolumeUnmounted = (data: { volumeName: string }) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "volume:unmounted",
});
};
const onVolumeUpdated = (data: { volumeName: string }) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "volume:updated",
});
};
serverEvents.on("backup:started", onBackupStarted);
serverEvents.on("backup:progress", onBackupProgress);
serverEvents.on("backup:completed", onBackupCompleted);
serverEvents.on("volume:mounted", onVolumeMounted);
serverEvents.on("volume:unmounted", onVolumeUnmounted);
serverEvents.on("volume:updated", onVolumeUpdated);
let keepAlive = true;
stream.onAbort(() => {
logger.info("Client disconnected from SSE endpoint");
keepAlive = false;
serverEvents.off("backup:started", onBackupStarted);
serverEvents.off("backup:progress", onBackupProgress);
serverEvents.off("backup:completed", onBackupCompleted);
serverEvents.off("volume:mounted", onVolumeMounted);
serverEvents.off("volume:unmounted", onVolumeUnmounted);
serverEvents.off("volume:updated", onVolumeUpdated);
});
while (keepAlive) {
await stream.writeSSE({
data: JSON.stringify({ timestamp: Date.now() }),
event: "heartbeat",
});
await stream.sleep(5000);
}
});
});

View File

@@ -0,0 +1,28 @@
import { Scheduler } from "../../core/scheduler";
import { eq, or } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { logger } from "../../utils/logger";
import { SOCKET_PATH } from "../../core/constants";
import { createVolumeBackend } from "../backends/backend";
export const shutdown = async () => {
await Scheduler.stop();
await Bun.file(SOCKET_PATH)
.delete()
.catch(() => {
// Ignore errors if the socket file does not exist
});
const volumes = await db.query.volumesTable.findMany({
where: or(eq(volumesTable.status, "mounted")),
});
for (const volume of volumes) {
const backend = createVolumeBackend(volume);
const { status, error } = await backend.unmount();
logger.info(`Volume ${volume.name} unmount status: ${status}${error ? `, error: ${error}` : ""}`);
}
};

View File

@@ -0,0 +1,39 @@
import { Scheduler } from "../../core/scheduler";
import { and, eq, or } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { logger } from "../../utils/logger";
import { restic } from "../../utils/restic";
import { volumeService } from "../volumes/volume.service";
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
import { BackupExecutionJob } from "../../jobs/backup-execution";
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
export const startup = async () => {
await Scheduler.start();
await restic.ensurePassfile().catch((err) => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
});
const volumes = await db.query.volumesTable.findMany({
where: or(
eq(volumesTable.status, "mounted"),
and(eq(volumesTable.autoRemount, true), eq(volumesTable.status, "error")),
),
});
for (const volume of volumes) {
await volumeService.mountVolume(volume.name).catch((err) => {
logger.error(`Error auto-remounting volume ${volume.name} on startup: ${err.message}`);
});
}
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
Scheduler.build(RepositoryHealthCheckJob).schedule("*/10 * * * *");
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
};

View File

@@ -0,0 +1,145 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createRepositoryBody,
createRepositoryDto,
deleteRepositoryDto,
doctorRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
listRcloneRemotesDto,
listRepositoriesDto,
listSnapshotFilesDto,
listSnapshotFilesQuery,
listSnapshotsDto,
listSnapshotsFilters,
restoreSnapshotBody,
restoreSnapshotDto,
type DeleteRepositoryDto,
type DoctorRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
type ListRepositoriesDto,
type ListSnapshotFilesDto,
type ListSnapshotsDto,
type RestoreSnapshotDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
export const repositoriesController = new Hono()
.get("/", listRepositoriesDto, async (c) => {
const repositories = await repositoriesService.listRepositories();
return c.json<ListRepositoriesDto>(repositories, 200);
})
.post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => {
const body = c.req.valid("json");
const res = await repositoriesService.createRepository(body.name, body.config, body.compressionMode);
return c.json({ message: "Repository created", repository: res.repository }, 201);
})
.get("/rclone-remotes", listRcloneRemotesDto, async (c) => {
const remoteNames = await listRcloneRemotes();
const remotes = await Promise.all(
remoteNames.map(async (name) => {
const info = await getRcloneRemoteInfo(name);
return {
name,
type: info?.type ?? "unknown",
};
}),
);
return c.json(remotes);
})
.get("/:name", getRepositoryDto, async (c) => {
const { name } = c.req.param();
const res = await repositoriesService.getRepository(name);
return c.json<GetRepositoryDto>(res.repository, 200);
})
.delete("/:name", deleteRepositoryDto, async (c) => {
const { name } = c.req.param();
await repositoriesService.deleteRepository(name);
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
})
.get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
const { name } = c.req.param();
const { backupId } = c.req.valid("query");
const res = await repositoriesService.listSnapshots(name, backupId);
const snapshots = res.map((snapshot) => {
const { summary } = snapshot;
let duration = 0;
if (summary) {
const { backup_start, backup_end } = summary;
duration = new Date(backup_end).getTime() - new Date(backup_start).getTime();
}
return {
short_id: snapshot.short_id,
duration,
paths: snapshot.paths,
size: summary?.total_bytes_processed || 0,
time: new Date(snapshot.time).getTime(),
};
});
return c.json<ListSnapshotsDto>(snapshots, 200);
})
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
const { name, snapshotId } = c.req.param();
const snapshot = await repositoriesService.getSnapshotDetails(name, snapshotId);
let duration = 0;
if (snapshot.summary) {
const { backup_start, backup_end } = snapshot.summary;
duration = new Date(backup_end).getTime() - new Date(backup_start).getTime();
}
const response = {
short_id: snapshot.short_id,
duration,
time: new Date(snapshot.time).getTime(),
paths: snapshot.paths,
size: snapshot.summary?.total_bytes_processed || 0,
summary: snapshot.summary,
};
return c.json<GetSnapshotDetailsDto>(response, 200);
})
.get(
"/:name/snapshots/:snapshotId/files",
listSnapshotFilesDto,
validator("query", listSnapshotFilesQuery),
async (c) => {
const { name, snapshotId } = c.req.param();
const { path } = c.req.valid("query");
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
return c.json<ListSnapshotFilesDto>(result, 200);
},
)
.post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
const { name } = c.req.param();
const { snapshotId, ...options } = c.req.valid("json");
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
return c.json<RestoreSnapshotDto>(result, 200);
})
.post("/:name/doctor", doctorRepositoryDto, async (c) => {
const { name } = c.req.param();
const result = await repositoriesService.doctorRepository(name);
return c.json<DoctorRepositoryDto>(result, 200);
});

View File

@@ -0,0 +1,328 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryConfigSchema } from "~/schemas/restic";
export const repositorySchema = type({
id: "string",
name: "string",
type: type.valueOf(REPOSITORY_BACKENDS),
config: repositoryConfigSchema,
compressionMode: type.valueOf(COMPRESSION_MODES).or("null"),
status: type.valueOf(REPOSITORY_STATUS).or("null"),
lastChecked: "number | null",
lastError: "string | null",
createdAt: "number",
updatedAt: "number",
});
export type RepositoryDto = typeof repositorySchema.infer;
/**
* List all repositories
*/
export const listRepositoriesResponse = repositorySchema.array();
export type ListRepositoriesDto = typeof listRepositoriesResponse.infer;
export const listRepositoriesDto = describeRoute({
description: "List all repositories",
tags: ["Repositories"],
operationId: "listRepositories",
responses: {
200: {
description: "List of repositories",
content: {
"application/json": {
schema: resolver(listRepositoriesResponse),
},
},
},
},
});
/**
* Create a new repository
*/
export const createRepositoryBody = type({
name: "string",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
config: repositoryConfigSchema,
});
export type CreateRepositoryBody = typeof createRepositoryBody.infer;
export const createRepositoryResponse = type({
message: "string",
repository: type({
id: "string",
name: "string",
}),
});
export type CreateRepositoryDto = typeof createRepositoryResponse.infer;
export const createRepositoryDto = describeRoute({
description: "Create a new restic repository",
operationId: "createRepository",
tags: ["Repositories"],
responses: {
201: {
description: "Repository created successfully",
content: {
"application/json": {
schema: resolver(createRepositoryResponse),
},
},
},
},
});
/**
* Get a single repository
*/
export const getRepositoryResponse = repositorySchema;
export type GetRepositoryDto = typeof getRepositoryResponse.infer;
export const getRepositoryDto = describeRoute({
description: "Get a single repository by name",
tags: ["Repositories"],
operationId: "getRepository",
responses: {
200: {
description: "Repository details",
content: {
"application/json": {
schema: resolver(getRepositoryResponse),
},
},
},
},
});
/**
* Delete a repository
*/
export const deleteRepositoryResponse = type({
message: "string",
});
export type DeleteRepositoryDto = typeof deleteRepositoryResponse.infer;
export const deleteRepositoryDto = describeRoute({
description: "Delete a repository",
tags: ["Repositories"],
operationId: "deleteRepository",
responses: {
200: {
description: "Repository deleted successfully",
content: {
"application/json": {
schema: resolver(deleteRepositoryResponse),
},
},
},
},
});
/**
* List snapshots in a repository
*/
export const snapshotSchema = type({
short_id: "string",
time: "number",
paths: "string[]",
size: "number",
duration: "number",
});
const listSnapshotsResponse = snapshotSchema.array();
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
export const listSnapshotsFilters = type({
backupId: "string?",
});
export const listSnapshotsDto = describeRoute({
description: "List all snapshots in a repository",
tags: ["Repositories"],
operationId: "listSnapshots",
responses: {
200: {
description: "List of snapshots",
content: {
"application/json": {
schema: resolver(listSnapshotsResponse),
},
},
},
},
});
/**
* Get snapshot details
*/
export const getSnapshotDetailsResponse = snapshotSchema;
export type GetSnapshotDetailsDto = typeof getSnapshotDetailsResponse.infer;
export const getSnapshotDetailsDto = describeRoute({
description: "Get details of a specific snapshot",
tags: ["Repositories"],
operationId: "getSnapshotDetails",
responses: {
200: {
description: "Snapshot details",
content: {
"application/json": {
schema: resolver(getSnapshotDetailsResponse),
},
},
},
},
});
/**
* List files in a snapshot
*/
export const snapshotFileNodeSchema = type({
name: "string",
type: "string",
path: "string",
uid: "number?",
gid: "number?",
size: "number?",
mode: "number?",
mtime: "string?",
atime: "string?",
ctime: "string?",
});
export const listSnapshotFilesResponse = type({
snapshot: type({
id: "string",
short_id: "string",
time: "string",
hostname: "string",
paths: "string[]",
}),
files: snapshotFileNodeSchema.array(),
});
export type ListSnapshotFilesDto = typeof listSnapshotFilesResponse.infer;
export const listSnapshotFilesQuery = type({
path: "string?",
});
export const listSnapshotFilesDto = describeRoute({
description: "List files and directories in a snapshot",
tags: ["Repositories"],
operationId: "listSnapshotFiles",
responses: {
200: {
description: "List of files and directories in the snapshot",
content: {
"application/json": {
schema: resolver(listSnapshotFilesResponse),
},
},
},
},
});
/**
* Restore a snapshot
*/
export const restoreSnapshotBody = type({
snapshotId: "string",
include: "string[]?",
exclude: "string[]?",
delete: "boolean?",
});
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;
export const restoreSnapshotResponse = type({
success: "boolean",
message: "string",
filesRestored: "number",
filesSkipped: "number",
});
export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer;
export const restoreSnapshotDto = describeRoute({
description: "Restore a snapshot to a target path on the filesystem",
tags: ["Repositories"],
operationId: "restoreSnapshot",
responses: {
200: {
description: "Snapshot restored successfully",
content: {
"application/json": {
schema: resolver(restoreSnapshotResponse),
},
},
},
},
});
/**
* Doctor a repository (unlock, check, repair)
*/
export const doctorStepSchema = type({
step: "string",
success: "boolean",
output: "string | null",
error: "string | null",
});
export const doctorRepositoryResponse = type({
success: "boolean",
steps: doctorStepSchema.array(),
});
export type DoctorRepositoryDto = typeof doctorRepositoryResponse.infer;
export const doctorRepositoryDto = describeRoute({
description:
"Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.",
tags: ["Repositories"],
operationId: "doctorRepository",
responses: {
200: {
description: "Doctor operation completed",
content: {
"application/json": {
schema: resolver(doctorRepositoryResponse),
},
},
},
},
});
/**
* List rclone available remotes
*/
const rcloneRemoteSchema = type({
name: "string",
type: "string",
});
const listRcloneRemotesResponse = rcloneRemoteSchema.array();
export const listRcloneRemotesDto = describeRoute({
description: "List all configured rclone remotes on the host system",
tags: ["Rclone"],
operationId: "listRcloneRemotes",
responses: {
200: {
description: "List of rclone remotes",
content: {
"application/json": {
schema: resolver(listRcloneRemotesResponse),
},
},
},
},
});

View File

@@ -0,0 +1,328 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({});
return repositories;
};
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
const encryptedConfig: Record<string, string> = { ...config };
switch (config.backend) {
case "s3":
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
break;
case "gcs":
encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson);
break;
case "azure":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
break;
}
return encryptedConfig as RepositoryConfig;
};
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
const slug = slugify(name, { lower: true, strict: true });
const existing = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, slug),
});
if (existing) {
throw new ConflictError("Repository with this name already exists");
}
const id = crypto.randomUUID();
const encryptedConfig = await encryptConfig(config);
const [created] = await db
.insert(repositoriesTable)
.values({
id,
name: slug,
type: config.backend,
config: encryptedConfig,
compressionMode: compressionMode ?? "auto",
status: "unknown",
})
.returning();
if (!created) {
throw new InternalServerError("Failed to create repository");
}
const { success, error } = await restic.init(encryptedConfig);
if (success) {
await db
.update(repositoriesTable)
.set({
status: "healthy",
lastChecked: Date.now(),
lastError: null,
})
.where(eq(repositoriesTable.id, id));
return { repository: created, status: 201 };
}
const errorMessage = toMessage(error);
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
};
const getRepository = async (name: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
return { repository };
};
const deleteRepository = async (name: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
// TODO: Add cleanup logic for the actual restic repository files
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
};
/**
* List snapshots for a given repository
* If backupId is provided, filter snapshots by that backup ID (tag)
* @param name Repository name
* @param backupId Optional backup ID to filter snapshots for a specific backup schedule
*
* @returns List of snapshots
*/
const listSnapshots = async (name: string, backupId?: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
let snapshots = [];
if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
} else {
snapshots = await restic.snapshots(repository.config);
}
return snapshots;
};
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const result = await restic.ls(repository.config, snapshotId, path);
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
}
return {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
};
};
const restoreSnapshot = async (
name: string,
snapshotId: string,
options?: { include?: string[]; exclude?: string[]; delete?: boolean },
) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const result = await restic.restore(repository.config, snapshotId, "/", options);
return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
};
};
const getSnapshotDetails = async (name: string, snapshotId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const snapshots = await restic.snapshots(repository.config);
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
if (!snapshot) {
throw new NotFoundError("Snapshot not found");
}
return snapshot;
};
const checkHealth = async (repositoryId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const { error, status } = await restic
.snapshots(repository.config)
.then(() => ({ error: null, status: "healthy" as const }))
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
await db
.update(repositoriesTable)
.set({
status,
lastChecked: Date.now(),
lastError: error,
})
.where(eq(repositoriesTable.id, repository.id));
return { status, lastError: error };
};
const doctorRepository = async (name: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = [];
const unlockResult = await restic.unlock(repository.config).then(
(result) => ({ success: true, message: result.message, error: null }),
(error) => ({ success: false, message: null, error: toMessage(error) }),
);
steps.push({
step: "unlock",
success: unlockResult.success,
output: unlockResult.message,
error: unlockResult.error,
});
const checkResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "check",
success: checkResult.success,
output: checkResult.output,
error: checkResult.error,
});
if (checkResult.hasErrors) {
const repairResult = await restic.repairIndex(repository.config).then(
(result) => ({ success: true, output: result.output, error: null }),
(error) => ({ success: false, output: null, error: toMessage(error) }),
);
steps.push({
step: "repair_index",
success: repairResult.success,
output: repairResult.output,
error: repairResult.error,
});
const recheckResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "recheck",
success: recheckResult.success,
output: recheckResult.output,
error: recheckResult.error,
});
}
const allSuccessful = steps.every((s) => s.success);
console.log("Doctor steps:", steps);
await db
.update(repositoriesTable)
.set({
status: allSuccessful ? "healthy" : "error",
lastChecked: Date.now(),
lastError: allSuccessful ? null : steps.find((s) => !s.success)?.error,
})
.where(eq(repositoriesTable.id, repository.id));
return {
success: allSuccessful,
steps,
};
};
export const repositoriesService = {
listRepositories,
createRepository,
getRepository,
deleteRepository,
listSnapshots,
listSnapshotFiles,
restoreSnapshot,
getSnapshotDetails,
checkHealth,
doctorRepository,
};

View File

@@ -0,0 +1,57 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
downloadResticPasswordBodySchema,
downloadResticPasswordDto,
systemInfoDto,
type SystemInfoDto,
} from "./system.dto";
import { systemService } from "./system.service";
import { requireAuth } from "../auth/auth.middleware";
import { RESTIC_PASS_FILE } from "../../core/constants";
import { db } from "../../db/db";
import { usersTable } from "../../db/schema";
import { eq } from "drizzle-orm";
export const systemController = new Hono()
.get("/info", systemInfoDto, async (c) => {
const info = await systemService.getSystemInfo();
return c.json<SystemInfoDto>(info, 200);
})
.post(
"/restic-password",
downloadResticPasswordDto,
requireAuth,
validator("json", downloadResticPasswordBodySchema),
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);
}
try {
const file = Bun.file(RESTIC_PASS_FILE);
const content = await file.text();
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, user.id));
c.header("Content-Type", "text/plain");
c.header("Content-Disposition", 'attachment; filename="restic.pass"');
return c.text(content);
} catch (_error) {
return c.json({ message: "Failed to read Restic password file" }, 500);
}
},
);

View File

@@ -0,0 +1,49 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
export const capabilitiesSchema = type({
docker: "boolean",
rclone: "boolean",
});
export const systemInfoResponse = type({
capabilities: capabilitiesSchema,
});
export type SystemInfoDto = typeof systemInfoResponse.infer;
export const systemInfoDto = describeRoute({
description: "Get system information including available capabilities",
tags: ["System"],
operationId: "getSystemInfo",
responses: {
200: {
description: "System information with enabled capabilities",
content: {
"application/json": {
schema: resolver(systemInfoResponse),
},
},
},
},
});
export const downloadResticPasswordBodySchema = type({
password: "string",
});
export const downloadResticPasswordDto = describeRoute({
description: "Download the Restic password file for backup recovery. Requires password re-authentication.",
tags: ["System"],
operationId: "downloadResticPassword",
responses: {
200: {
description: "Restic password file content",
content: {
"text/plain": {
schema: { type: "string" },
},
},
},
},
});

View File

@@ -0,0 +1,11 @@
import { getCapabilities } from "../../core/capabilities";
const getSystemInfo = async () => {
return {
capabilities: await getCapabilities(),
};
};
export const systemService = {
getSystemInfo,
};

View File

@@ -0,0 +1,10 @@
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import type { Volume } from "../../db/schema";
export const getVolumePath = (volume: Volume) => {
if (volume.config.backend === "directory") {
return volume.config.path;
}
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
};

View File

@@ -0,0 +1,137 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createVolumeBody,
createVolumeDto,
deleteVolumeDto,
getContainersDto,
getVolumeDto,
healthCheckDto,
type ListVolumesDto,
listFilesDto,
listVolumesDto,
mountVolumeDto,
testConnectionBody,
testConnectionDto,
unmountVolumeDto,
updateVolumeBody,
updateVolumeDto,
type CreateVolumeDto,
type GetVolumeDto,
type ListContainersDto,
type UpdateVolumeDto,
type ListFilesDto,
browseFilesystemDto,
type BrowseFilesystemDto,
} from "./volume.dto";
import { volumeService } from "./volume.service";
import { getVolumePath } from "./helpers";
export const volumeController = new Hono()
.get("/", listVolumesDto, async (c) => {
const volumes = await volumeService.listVolumes();
return c.json<ListVolumesDto>(volumes, 200);
})
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
const body = c.req.valid("json");
const res = await volumeService.createVolume(body.name, body.config);
const response = {
...res.volume,
path: getVolumePath(res.volume),
};
return c.json<CreateVolumeDto>(response, 201);
})
.post("/test-connection", testConnectionDto, validator("json", testConnectionBody), async (c) => {
const body = c.req.valid("json");
const result = await volumeService.testConnection(body.config);
return c.json(result, 200);
})
.delete("/:name", deleteVolumeDto, async (c) => {
const { name } = c.req.param();
await volumeService.deleteVolume(name);
return c.json({ message: "Volume deleted" }, 200);
})
.get("/:name", getVolumeDto, async (c) => {
const { name } = c.req.param();
const res = await volumeService.getVolume(name);
const response = {
volume: {
...res.volume,
path: getVolumePath(res.volume),
},
statfs: {
total: res.statfs.total ?? 0,
used: res.statfs.used ?? 0,
free: res.statfs.free ?? 0,
},
};
return c.json<GetVolumeDto>(response, 200);
})
.get("/:name/containers", getContainersDto, async (c) => {
const { name } = c.req.param();
const { containers } = await volumeService.getContainersUsingVolume(name);
return c.json<ListContainersDto>(containers, 200);
})
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param();
const body = c.req.valid("json");
const res = await volumeService.updateVolume(name, body);
const response = {
...res.volume,
path: getVolumePath(res.volume),
};
return c.json<UpdateVolumeDto>(response, 200);
})
.post("/:name/mount", mountVolumeDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.mountVolume(name);
return c.json({ error, status }, error ? 500 : 200);
})
.post("/:name/unmount", unmountVolumeDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.unmountVolume(name);
return c.json({ error, status }, error ? 500 : 200);
})
.post("/:name/health-check", healthCheckDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.checkHealth(name);
return c.json({ error, status }, 200);
})
.get("/:name/files", listFilesDto, async (c) => {
const { name } = c.req.param();
const subPath = c.req.query("path");
const result = await volumeService.listFiles(name, subPath);
const response = {
files: result.files,
path: result.path,
};
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
return c.json<ListFilesDto>(response, 200);
})
.get("/filesystem/browse", browseFilesystemDto, async (c) => {
const path = c.req.query("path") || "/";
const result = await volumeService.browseFilesystem(path);
const response = {
directories: result.directories,
path: result.path,
};
return c.json<BrowseFilesystemDto>(response, 200);
});

View File

@@ -0,0 +1,373 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes";
export const volumeSchema = type({
id: "number",
name: "string",
type: type.valueOf(BACKEND_TYPES),
status: type.valueOf(BACKEND_STATUS),
lastError: "string | null",
createdAt: "number",
updatedAt: "number",
lastHealthCheck: "number",
config: volumeConfigSchema,
autoRemount: "boolean",
});
export type VolumeDto = typeof volumeSchema.infer;
/**
* List all volumes
*/
export const listVolumesResponse = volumeSchema.array();
export type ListVolumesDto = typeof listVolumesResponse.infer;
export const listVolumesDto = describeRoute({
description: "List all volumes",
tags: ["Volumes"],
operationId: "listVolumes",
responses: {
200: {
description: "A list of volumes",
content: {
"application/json": {
schema: resolver(listVolumesResponse),
},
},
},
},
});
/**
* Create a new volume
*/
export const createVolumeBody = type({
name: "string",
config: volumeConfigSchema,
});
export const createVolumeResponse = volumeSchema;
export type CreateVolumeDto = typeof createVolumeResponse.infer;
export const createVolumeDto = describeRoute({
description: "Create a new volume",
operationId: "createVolume",
tags: ["Volumes"],
responses: {
201: {
description: "Volume created successfully",
content: {
"application/json": {
schema: resolver(createVolumeResponse),
},
},
},
},
});
/**
* Delete a volume
*/
export const deleteVolumeResponse = type({
message: "string",
});
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer;
export const deleteVolumeDto = describeRoute({
description: "Delete a volume",
operationId: "deleteVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume deleted successfully",
content: {
"application/json": {
schema: resolver(deleteVolumeResponse),
},
},
},
},
});
const statfsSchema = type({
total: "number",
used: "number",
free: "number",
});
const getVolumeResponse = type({
volume: volumeSchema,
statfs: statfsSchema,
});
export type GetVolumeDto = typeof getVolumeResponse.infer;
/**
* Get a volume
*/
export const getVolumeDto = describeRoute({
description: "Get a volume by name",
operationId: "getVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume details",
content: {
"application/json": {
schema: resolver(getVolumeResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* Update a volume
*/
export const updateVolumeBody = type({
autoRemount: "boolean?",
config: volumeConfigSchema.optional(),
});
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
export const updateVolumeResponse = volumeSchema;
export type UpdateVolumeDto = typeof updateVolumeResponse.infer;
export const updateVolumeDto = describeRoute({
description: "Update a volume's configuration",
operationId: "updateVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume updated successfully",
content: {
"application/json": {
schema: resolver(updateVolumeResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* Test connection
*/
export const testConnectionBody = type({
config: volumeConfigSchema,
});
export const testConnectionResponse = type({
success: "boolean",
message: "string",
});
export type TestConnectionDto = typeof testConnectionResponse.infer;
export const testConnectionDto = describeRoute({
description: "Test connection to backend",
operationId: "testConnection",
tags: ["Volumes"],
responses: {
200: {
description: "Connection test result",
content: {
"application/json": {
schema: resolver(testConnectionResponse),
},
},
},
},
});
/**
* Mount volume
*/
export const mountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type MountVolumeDto = typeof mountVolumeResponse.infer;
export const mountVolumeDto = describeRoute({
description: "Mount a volume",
operationId: "mountVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume mounted successfully",
content: {
"application/json": {
schema: resolver(mountVolumeResponse),
},
},
},
},
});
/**
* Unmount volume
*/
export const unmountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer;
export const unmountVolumeDto = describeRoute({
description: "Unmount a volume",
operationId: "unmountVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume unmounted successfully",
content: {
"application/json": {
schema: resolver(unmountVolumeResponse),
},
},
},
},
});
export const healthCheckResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type HealthCheckDto = typeof healthCheckResponse.infer;
export const healthCheckDto = describeRoute({
description: "Perform a health check on a volume",
operationId: "healthCheckVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume health check result",
content: {
"application/json": {
schema: resolver(healthCheckResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* Get containers using a volume
*/
const containerSchema = type({
id: "string",
name: "string",
state: "string",
image: "string",
});
export const listContainersResponse = containerSchema.array();
export type ListContainersDto = typeof listContainersResponse.infer;
export const getContainersDto = describeRoute({
description: "Get containers using a volume by name",
operationId: "getContainersUsingVolume",
tags: ["Volumes"],
responses: {
200: {
description: "List of containers using the volume",
content: {
"application/json": {
schema: resolver(listContainersResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* List files in a volume
*/
const fileEntrySchema = type({
name: "string",
path: "string",
type: type.enumerated("file", "directory"),
size: "number?",
modifiedAt: "number?",
});
export const listFilesResponse = type({
files: fileEntrySchema.array(),
path: "string",
});
export type ListFilesDto = typeof listFilesResponse.infer;
export const listFilesDto = describeRoute({
description: "List files in a volume directory",
operationId: "listFiles",
tags: ["Volumes"],
parameters: [
{
in: "query",
name: "path",
required: false,
schema: {
type: "string",
},
description: "Subdirectory path to list (relative to volume root)",
},
],
responses: {
200: {
description: "List of files in the volume",
content: {
"application/json": {
schema: resolver(listFilesResponse),
},
},
},
},
});
/**
* Browse filesystem directories
*/
export const browseFilesystemResponse = type({
directories: fileEntrySchema.array(),
path: "string",
});
export type BrowseFilesystemDto = typeof browseFilesystemResponse.infer;
export const browseFilesystemDto = describeRoute({
description: "Browse directories on the host filesystem",
operationId: "browseFilesystem",
tags: ["Volumes"],
parameters: [
{
in: "query",
name: "path",
required: false,
schema: {
type: "string",
},
description: "Directory path to browse (absolute path, defaults to /)",
},
],
responses: {
200: {
description: "List of directories in the specified path",
content: {
"application/json": {
schema: resolver(browseFilesystemResponse),
},
},
},
},
});

View File

@@ -0,0 +1,406 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import Docker from "dockerode";
import { eq } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { getCapabilities } from "../../core/capabilities";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend";
import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes";
const listVolumes = async () => {
const volumes = await db.query.volumesTable.findMany({});
return volumes;
};
const createVolume = async (name: string, backendConfig: BackendConfig) => {
const slug = slugify(name, { lower: true, strict: true });
const existing = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, slug),
});
if (existing) {
throw new ConflictError("Volume already exists");
}
const [created] = await db
.insert(volumesTable)
.values({
name: slug,
config: backendConfig,
type: backendConfig.backend,
})
.returning();
if (!created) {
throw new InternalServerError("Failed to create volume");
}
const backend = createVolumeBackend(created);
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, slug));
return { volume: created, status: 201 };
};
const deleteVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
await backend.unmount();
await db.delete(volumesTable).where(eq(volumesTable.name, name));
};
const mountVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
if (status === "mounted") {
serverEvents.emit("volume:mounted", { volumeName: name });
}
return { error, status };
};
const unmountVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
const { status, error } = await backend.unmount();
await db.update(volumesTable).set({ status }).where(eq(volumesTable.name, name));
if (status === "unmounted") {
serverEvents.emit("volume:unmounted", { volumeName: name });
}
return { error, status };
};
const getVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
let statfs: Partial<StatFs> = {};
if (volume.status === "mounted") {
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
return {};
});
}
return { volume, statfs };
};
const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const existing = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!existing) {
throw new NotFoundError("Volume not found");
}
const configChanged =
JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined;
if (configChanged) {
logger.debug("Unmounting existing volume before applying new config");
const backend = createVolumeBackend(existing);
await backend.unmount();
}
const [updated] = await db
.update(volumesTable)
.set({
config: volumeData.config,
type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount,
updatedAt: Date.now(),
})
.where(eq(volumesTable.name, name))
.returning();
if (!updated) {
throw new InternalServerError("Failed to update volume");
}
if (configChanged) {
const backend = createVolumeBackend(updated);
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
serverEvents.emit("volume:updated", { volumeName: name });
}
return { volume: updated };
};
const testConnection = async (backendConfig: BackendConfig) => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ironmount-test-"));
const mockVolume = {
id: 0,
name: "test-connection",
path: tempDir,
config: backendConfig,
createdAt: Date.now(),
updatedAt: Date.now(),
lastHealthCheck: Date.now(),
type: backendConfig.backend,
status: "unmounted" as const,
lastError: null,
autoRemount: true,
};
const backend = createVolumeBackend(mockVolume);
const { error } = await backend.mount();
await backend.unmount();
await fs.access(tempDir);
await fs.rm(tempDir, { recursive: true, force: true });
return {
success: !error,
message: error ? toMessage(error) : "Connection successful",
};
};
const checkHealth = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
const { error, status } = await backend.checkHealth();
if (status !== volume.status) {
serverEvents.emit("volume:status_changed", { volumeName: name, status });
}
await db
.update(volumesTable)
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
.where(eq(volumesTable.name, volume.name));
return { status, error };
};
const getContainersUsingVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const { docker } = await getCapabilities();
if (!docker) {
logger.debug("Docker capability not available, returning empty containers list");
return { containers: [] };
}
try {
const docker = new Docker();
const containers = await docker.listContainers({ all: true });
const usingContainers = [];
for (const info of containers) {
const container = docker.getContainer(info.Id);
const inspect = await container.inspect();
const mounts = inspect.Mounts || [];
const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`);
if (usesVolume) {
usingContainers.push({
id: inspect.Id,
name: inspect.Name,
state: inspect.State.Status,
image: inspect.Config.Image,
});
}
}
return { containers: usingContainers };
} catch (error) {
logger.error(`Failed to get containers using volume: ${toMessage(error)}`);
return { containers: [] };
}
};
const listFiles = async (name: string, subPath?: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
if (volume.status !== "mounted") {
throw new InternalServerError("Volume is not mounted");
}
// For directory volumes, use the configured path directly
const volumePath = getVolumePath(volume);
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
const normalizedPath = path.normalize(requestedPath);
if (!normalizedPath.startsWith(volumePath)) {
throw new InternalServerError("Invalid path");
}
try {
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name);
const relativePath = path.relative(volumePath, fullPath);
try {
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: entry.isFile() ? stats.size : undefined,
modifiedAt: stats.mtimeMs,
};
} catch {
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: undefined,
modifiedAt: undefined,
};
}
}),
);
return {
files: files.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
}),
path: subPath || "/",
};
} catch (error) {
throw new InternalServerError(`Failed to list files: ${toMessage(error)}`);
}
};
const browseFilesystem = async (browsePath: string) => {
const normalizedPath = path.normalize(browsePath);
try {
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
const directories = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name);
try {
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: fullPath,
type: "directory" as const,
size: undefined,
modifiedAt: stats.mtimeMs,
};
} catch {
return {
name: entry.name,
path: fullPath,
type: "directory" as const,
size: undefined,
modifiedAt: undefined,
};
}
}),
);
return {
directories: directories.sort((a, b) => a.name.localeCompare(b.name)),
path: normalizedPath,
};
} catch (error) {
throw new InternalServerError(`Failed to browse filesystem: ${toMessage(error)}`);
}
};
export const volumeService = {
listVolumes,
createVolume,
mountVolume,
deleteVolume,
getVolume,
updateVolume,
testConnection,
unmountVolume,
checkHealth,
getContainersUsingVolume,
listFiles,
browseFilesystem,
};