diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 6fee3f9..d6611e1 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -37,6 +37,7 @@ import { getBackupScheduleForVolume, runBackupNow, getSystemInfo, + downloadResticPassword, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import type { @@ -94,6 +95,9 @@ import type { RunBackupNowData, RunBackupNowResponse, GetSystemInfoData, + DownloadResticPasswordData, + DownloadResticPasswordError, + DownloadResticPasswordResponse, } from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; @@ -1123,3 +1127,51 @@ export const getSystemInfoOptions = (options?: Options) => { queryKey: getSystemInfoQueryKey(options), }); }; + +export const downloadResticPasswordQueryKey = (options?: Options) => + createQueryKey("downloadResticPassword", options); + +/** + * Download the Restic password file for backup recovery. Requires password re-authentication. + */ +export const downloadResticPasswordOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await downloadResticPassword({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: downloadResticPasswordQueryKey(options), + }); +}; + +/** + * Download the Restic password file for backup recovery. Requires password re-authentication. + */ +export const downloadResticPasswordMutation = ( + options?: Partial>, +): UseMutationOptions< + DownloadResticPasswordResponse, + DownloadResticPasswordError, + Options +> => { + const mutationOptions: UseMutationOptions< + DownloadResticPasswordResponse, + DownloadResticPasswordError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await downloadResticPassword({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index ada9f0b..3222a98 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -76,6 +76,9 @@ import type { RunBackupNowResponses, GetSystemInfoData, GetSystemInfoResponses, + DownloadResticPasswordData, + DownloadResticPasswordResponses, + DownloadResticPasswordErrors, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -541,3 +544,23 @@ export const getSystemInfo = ( ...options, }); }; + +/** + * Download the Restic password file for backup recovery. Requires password re-authentication. + */ +export const downloadResticPassword = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).post< + DownloadResticPasswordResponses, + DownloadResticPasswordErrors, + ThrowOnError + >({ + url: "/api/v1/system/restic-password", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); +}; diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 6ea9372..09b90cd 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -18,6 +18,7 @@ export type RegisterResponses = { message: string; success: boolean; user?: { + hasDownloadedResticPassword: boolean; id: number; username: string; }; @@ -44,6 +45,7 @@ export type LoginResponses = { message: string; success: boolean; user?: { + hasDownloadedResticPassword: boolean; id: number; username: string; }; @@ -85,6 +87,7 @@ export type GetMeResponses = { message: string; success: boolean; user?: { + hasDownloadedResticPassword: boolean; id: number; username: string; }; @@ -149,6 +152,8 @@ export type ListVolumesResponses = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -175,6 +180,7 @@ export type ListVolumesResponses = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -196,6 +202,8 @@ export type CreateVolumeData = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -222,6 +230,7 @@ export type CreateVolumeData = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -241,6 +250,8 @@ export type CreateVolumeResponses = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -267,6 +278,7 @@ export type CreateVolumeResponses = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -288,6 +300,8 @@ export type TestConnectionData = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -314,6 +328,7 @@ export type TestConnectionData = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -386,6 +401,8 @@ export type GetVolumeResponses = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -412,6 +429,7 @@ export type GetVolumeResponses = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -435,6 +453,8 @@ export type UpdateVolumeData = { config?: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -461,6 +481,7 @@ export type UpdateVolumeData = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -488,6 +509,8 @@ export type UpdateVolumeResponses = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -514,6 +537,7 @@ export type UpdateVolumeResponses = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -962,12 +986,11 @@ export type DoctorRepositoryResponses = { * Doctor operation completed */ 200: { - message: string; steps: Array<{ + error: string | null; + output: string | null; step: string; success: boolean; - error?: string; - output?: string; }>; success: boolean; }; @@ -1036,6 +1059,8 @@ export type ListBackupSchedulesResponses = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -1062,6 +1087,7 @@ export type ListBackupSchedulesResponses = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -1219,6 +1245,8 @@ export type GetBackupScheduleResponses = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -1245,6 +1273,7 @@ export type GetBackupScheduleResponses = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -1383,6 +1412,8 @@ export type GetBackupScheduleForVolumeResponses = { config: | { backend: "directory"; + path: string; + readOnly?: false; } | { backend: "nfs"; @@ -1409,6 +1440,7 @@ export type GetBackupScheduleForVolumeResponses = { server: string; port?: number; password?: string; + readOnly?: boolean; ssl?: boolean; username?: string; }; @@ -1468,6 +1500,35 @@ export type GetSystemInfoResponses = { export type GetSystemInfoResponse = GetSystemInfoResponses[keyof GetSystemInfoResponses]; +export type DownloadResticPasswordData = { + body?: { + password: string; + }; + path?: never; + query?: never; + url: "/api/v1/system/restic-password"; +}; + +export type DownloadResticPasswordErrors = { + /** + * Authentication required or incorrect password + */ + 401: { + message?: string; + }; +}; + +export type DownloadResticPasswordError = DownloadResticPasswordErrors[keyof DownloadResticPasswordErrors]; + +export type DownloadResticPasswordResponses = { + /** + * Restic password file content + */ + 200: string; +}; + +export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses]; + export type ClientOptions = { baseUrl: "http://192.168.2.42:4096" | (string & {}); }; diff --git a/apps/client/app/app.css b/apps/client/app/app.css index 26af738..b7b1465 100644 --- a/apps/client/app/app.css +++ b/apps/client/app/app.css @@ -70,6 +70,8 @@ body { } :root { + color-scheme: dark; + --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); diff --git a/apps/client/app/components/layout.tsx b/apps/client/app/components/layout.tsx index 86aaf56..fae5a9f 100644 --- a/apps/client/app/components/layout.tsx +++ b/apps/client/app/components/layout.tsx @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { LifeBuoy } from "lucide-react"; -import { Outlet, useNavigate } from "react-router"; +import { Outlet, redirect, useNavigate } from "react-router"; import { toast } from "sonner"; import { logoutMutation } from "~/api-client/@tanstack/react-query.gen"; import { appContext } from "~/context"; @@ -16,6 +16,11 @@ export const clientMiddleware = [authMiddleware]; export async function clientLoader({ context }: Route.LoaderArgs) { const ctx = context.get(appContext); + + if (ctx.user && !ctx.user.hasDownloadedResticPassword) { + throw redirect("/download-recovery-key"); + } + return ctx; } diff --git a/apps/client/app/components/ui/alert.tsx b/apps/client/app/components/ui/alert.tsx index f072966..b33dad7 100644 --- a/apps/client/app/components/ui/alert.tsx +++ b/apps/client/app/components/ui/alert.tsx @@ -10,6 +10,7 @@ const alertVariants = cva( variant: { default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + warning: "border-orange-500/20 bg-orange-500/10 text-orange-500 [&>svg]:text-orange-500", }, }, defaultVariants: { diff --git a/apps/client/app/modules/auth/routes/download-recovery-key.tsx b/apps/client/app/modules/auth/routes/download-recovery-key.tsx new file mode 100644 index 0000000..95c0efb --- /dev/null +++ b/apps/client/app/modules/auth/routes/download-recovery-key.tsx @@ -0,0 +1,106 @@ +import { useMutation } from "@tanstack/react-query"; +import { AlertTriangle, Download } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { downloadResticPasswordMutation } from "~/api-client/@tanstack/react-query.gen"; +import { AuthLayout } from "~/components/auth-layout"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { authMiddleware } from "~/middleware/auth"; +import type { Route } from "./+types/download-recovery-key"; + +export const clientMiddleware = [authMiddleware]; + +export function meta(_: Route.MetaArgs) { + return [ + { title: "Download Recovery Key" }, + { + name: "description", + content: "Download your backup recovery key to ensure you can restore your data.", + }, + ]; +} + +export default function DownloadRecoveryKeyPage() { + const navigate = useNavigate(); + const [password, setPassword] = useState(""); + + const downloadResticPassword = useMutation({ + ...downloadResticPasswordMutation(), + onSuccess: (data) => { + const blob = new Blob([data], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "restic.pass"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + toast.success("Recovery key downloaded successfully!"); + navigate("/volumes", { replace: true }); + }, + onError: (error) => { + toast.error("Failed to download recovery key", { description: error.message }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!password) { + toast.error("Password is required"); + return; + } + + downloadResticPassword.mutate({ + body: { + password, + }, + }); + }; + + return ( + + + + Important: Save This File Securely + + Your Restic password is essential for recovering your backup data. If you lose access to this server without + this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage. + + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + disabled={downloadResticPassword.isPending} + /> +

Enter your account password to download the recovery key

+
+ +
+ +
+
+
+ ); +} diff --git a/apps/client/app/modules/auth/routes/login.tsx b/apps/client/app/modules/auth/routes/login.tsx index 51b082a..de2635a 100644 --- a/apps/client/app/modules/auth/routes/login.tsx +++ b/apps/client/app/modules/auth/routes/login.tsx @@ -44,8 +44,12 @@ export default function LoginPage() { const login = useMutation({ ...loginMutation(), - onSuccess: async () => { - navigate("/volumes"); + onSuccess: async (data) => { + if (data.user && !data.user.hasDownloadedResticPassword) { + navigate("/download-recovery-key"); + } else { + navigate("/volumes"); + } }, onError: (error) => { console.error(error); diff --git a/apps/client/app/modules/auth/routes/onboarding.tsx b/apps/client/app/modules/auth/routes/onboarding.tsx index b39e12e..58e5fa7 100644 --- a/apps/client/app/modules/auth/routes/onboarding.tsx +++ b/apps/client/app/modules/auth/routes/onboarding.tsx @@ -48,7 +48,7 @@ export default function OnboardingPage() { ...registerMutation(), onSuccess: async () => { toast.success("Admin user created successfully!"); - navigate("/volumes"); + navigate("/download-recovery-key"); }, onError: (error) => { console.error(error); diff --git a/apps/client/app/modules/repositories/tabs/info.tsx b/apps/client/app/modules/repositories/tabs/info.tsx index b6e5de4..0dd3f42 100644 --- a/apps/client/app/modules/repositories/tabs/info.tsx +++ b/apps/client/app/modules/repositories/tabs/info.tsx @@ -135,9 +135,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => { Doctor Results - - {doctorMutation.data?.message || "Repository doctor operation completed"} - + Repository doctor operation completed {doctorMutation.data && ( diff --git a/apps/client/app/modules/settings/routes/settings.tsx b/apps/client/app/modules/settings/routes/settings.tsx index cbdfa49..e188c63 100644 --- a/apps/client/app/modules/settings/routes/settings.tsx +++ b/apps/client/app/modules/settings/routes/settings.tsx @@ -1,11 +1,24 @@ import { useMutation } from "@tanstack/react-query"; -import { KeyRound, User } from "lucide-react"; +import { Download, KeyRound, User } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; -import { changePasswordMutation, logoutMutation } from "~/api-client/@tanstack/react-query.gen"; +import { + changePasswordMutation, + downloadResticPasswordMutation, + logoutMutation, +} from "~/api-client/@tanstack/react-query.gen"; import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Card, CardContent, CardDescription, CardTitle } from "~/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { appContext } from "~/context"; @@ -30,6 +43,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); + const [downloadPassword, setDownloadPassword] = useState(""); const navigate = useNavigate(); const logout = useMutation({ @@ -56,6 +71,28 @@ export default function Settings({ loaderData }: Route.ComponentProps) { }, }); + const downloadResticPassword = useMutation({ + ...downloadResticPasswordMutation(), + onSuccess: (data) => { + const blob = new Blob([data], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "restic.pass"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + toast.success("Restic password file downloaded successfully"); + setDownloadDialogOpen(false); + setDownloadPassword(""); + }, + onError: (error) => { + toast.error("Failed to download Restic password", { description: error.message }); + }, + }); + const handleChangePassword = (e: React.FormEvent) => { e.preventDefault(); @@ -77,6 +114,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) { }); }; + const handleDownloadResticPassword = (e: React.FormEvent) => { + e.preventDefault(); + + if (!downloadPassword) { + toast.error("Password is required"); + return; + } + + downloadResticPassword.mutate({ + body: { + password: downloadPassword, + }, + }); + }; + return (
@@ -143,6 +195,69 @@ export default function Settings({ loaderData }: Route.ComponentProps) { + +
+ + + Backup Recovery Key + + Download your Restic password file for disaster recovery +
+ +

+ This file contains the encryption password used by Restic to secure your backups. Store it in a safe place + (like a password manager or encrypted storage). If you lose access to this server, you'll need this file to + recover your backup data. +

+ + + + + + +
+ + Download Restic Password + + For security reasons, please enter your account password to download the Restic password file. + + +
+
+ + setDownloadPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + /> +
+
+ + + + +
+
+
+
); } diff --git a/apps/client/app/routes.ts b/apps/client/app/routes.ts index 21b96d8..98367d6 100644 --- a/apps/client/app/routes.ts +++ b/apps/client/app/routes.ts @@ -3,6 +3,7 @@ import { layout, type RouteConfig, route } from "@react-router/dev/routes"; export default [ route("onboarding", "./modules/auth/routes/onboarding.tsx"), route("login", "./modules/auth/routes/login.tsx"), + route("download-recovery-key", "./modules/auth/routes/download-recovery-key.tsx"), layout("./components/layout.tsx", [ route("/", "./routes/root.tsx"), route("volumes", "./modules/volumes/routes/volumes.tsx"), diff --git a/apps/server/drizzle/0010_perfect_proemial_gods.sql b/apps/server/drizzle/0010_perfect_proemial_gods.sql new file mode 100644 index 0000000..6c8f3a3 --- /dev/null +++ b/apps/server/drizzle/0010_perfect_proemial_gods.sql @@ -0,0 +1 @@ +ALTER TABLE `users_table` ADD `has_downloaded_restic_password` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0010_snapshot.json b/apps/server/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..f44673d --- /dev/null +++ b/apps/server/drizzle/meta/0010_snapshot.json @@ -0,0 +1,459 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "17f234ba-4123-4951-a39f-6002d537435f", + "prevId": "6a326ac0-cb3a-4c63-8800-bc86d18e0c1d", + "tables": { + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": [ + "volume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_downloaded_restic_password": { + "name": "has_downloaded_restic_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 333a110..52838a4 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1762095226041, "tag": "0009_little_adam_warlock", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1762610065889, + "tag": "0010_perfect_proemial_gods", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 018e880..8af1c18 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -32,6 +32,7 @@ export const usersTable = sqliteTable("users_table", { id: int().primaryKey({ autoIncrement: true }), username: text().notNull().unique(), passwordHash: text("password_hash").notNull(), + hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), }); diff --git a/apps/server/src/modules/auth/auth.controller.ts b/apps/server/src/modules/auth/auth.controller.ts index 23113b9..57fb5bd 100644 --- a/apps/server/src/modules/auth/auth.controller.ts +++ b/apps/server/src/modules/auth/auth.controller.ts @@ -43,7 +43,15 @@ export const authController = new Hono() }); return c.json( - { success: true, message: "User registered successfully", user: { id: user.id, username: user.username } }, + { + success: true, + message: "User registered successfully", + user: { + id: user.id, + username: user.username, + hasDownloadedResticPassword: user.hasDownloadedResticPassword, + }, + }, 201, ); } catch (error) { @@ -64,7 +72,11 @@ export const authController = new Hono() return c.json({ success: true, message: "Login successful", - user: { id: user.id, username: user.username }, + user: { + id: user.id, + username: user.username, + hasDownloadedResticPassword: user.hasDownloadedResticPassword, + }, }); } catch (error) { return c.json({ success: false, message: toMessage(error) }, 401); diff --git a/apps/server/src/modules/auth/auth.dto.ts b/apps/server/src/modules/auth/auth.dto.ts index 1d70765..f305f9c 100644 --- a/apps/server/src/modules/auth/auth.dto.ts +++ b/apps/server/src/modules/auth/auth.dto.ts @@ -18,6 +18,7 @@ const loginResponseSchema = type({ user: type({ id: "number", username: "string", + hasDownloadedResticPassword: "boolean", }).optional(), }); diff --git a/apps/server/src/modules/auth/auth.middleware.ts b/apps/server/src/modules/auth/auth.middleware.ts index 7966630..f94654e 100644 --- a/apps/server/src/modules/auth/auth.middleware.ts +++ b/apps/server/src/modules/auth/auth.middleware.ts @@ -15,6 +15,7 @@ declare module "hono" { user: { id: number; username: string; + hasDownloadedResticPassword: boolean; }; } } diff --git a/apps/server/src/modules/auth/auth.service.ts b/apps/server/src/modules/auth/auth.service.ts index d56ece3..3b762ec 100644 --- a/apps/server/src/modules/auth/auth.service.ts +++ b/apps/server/src/modules/auth/auth.service.ts @@ -38,7 +38,15 @@ export class AuthService { expiresAt, }); - return { user: { id: user.id, username: user.username, createdAt: user.createdAt }, sessionId }; + return { + user: { + id: user.id, + username: user.username, + createdAt: user.createdAt, + hasDownloadedResticPassword: user.hasDownloadedResticPassword, + }, + sessionId, + }; } /** @@ -70,7 +78,11 @@ export class AuthService { return { sessionId, - user: { id: user.id, username: user.username }, + user: { + id: user.id, + username: user.username, + hasDownloadedResticPassword: user.hasDownloadedResticPassword, + }, expiresAt, }; } @@ -109,6 +121,7 @@ export class AuthService { user: { id: session.user.id, username: session.user.username, + hasDownloadedResticPassword: session.user.hasDownloadedResticPassword, }, session: { id: session.session.id, diff --git a/apps/server/src/modules/system/system.controller.ts b/apps/server/src/modules/system/system.controller.ts index 5100f62..62d7aad 100644 --- a/apps/server/src/modules/system/system.controller.ts +++ b/apps/server/src/modules/system/system.controller.ts @@ -1,9 +1,57 @@ import { Hono } from "hono"; -import { systemInfoDto, type SystemInfoDto } from "./system.dto"; +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(); +export const systemController = new Hono() + .get("/info", systemInfoDto, async (c) => { + const info = await systemService.getSystemInfo(); - return c.json(info, 200); -}); + return c.json(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); + } + }, + ); diff --git a/apps/server/src/modules/system/system.dto.ts b/apps/server/src/modules/system/system.dto.ts index 806a3c6..dc9bc85 100644 --- a/apps/server/src/modules/system/system.dto.ts +++ b/apps/server/src/modules/system/system.dto.ts @@ -26,3 +26,23 @@ export const systemInfoDto = describeRoute({ }, }, }); + +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" }, + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index 201eb2a..939b0f5 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -130,11 +130,10 @@ const getVolume = async (name: string) => { let statfs: Partial = {}; if (volume.status === "mounted") { - statfs = await withTimeout(getStatFs(getVolumePath(volume)), OPERATION_TIMEOUT, "getStatFs") - .catch((error) => { - logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`); - return {}; - }); + 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 };