Merge remote-tracking branch 'upstream/main' into config-export-feature
This commit is contained in:
parent
b55bebf8f4
commit
764b64cc25
42 changed files with 1454 additions and 149 deletions
|
|
@ -1,4 +1,4 @@
|
|||
*
|
||||
**
|
||||
|
||||
!turbo.json
|
||||
!bun.lock
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ CLAUDE.md
|
|||
|
||||
mutagen.yml.lock
|
||||
notes.md
|
||||
smb-password.txt
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
|||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.18
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.19
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -74,7 +74,7 @@ If you only need to back up locally mounted folders and don't require remote sha
|
|||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.18
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.19
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
@ -106,7 +106,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.18
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.19
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -174,7 +174,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.18
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.19
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
|
|||
|
|
@ -173,3 +173,9 @@ body {
|
|||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
|
||||
[data-secret-input] input[type="password"]::-ms-reveal,
|
||||
[data-secret-input] input[type="password"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
|
|
@ -790,6 +790,23 @@ export const getMirrorCompatibilityOptions = (options: Options<GetMirrorCompatib
|
|||
queryKey: getMirrorCompatibilityQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Reorder backup schedules by providing an array of schedule IDs in the desired order
|
||||
*/
|
||||
export const reorderBackupSchedulesMutation = (options?: Partial<Options<ReorderBackupSchedulesData>>): UseMutationOptions<ReorderBackupSchedulesResponse, DefaultError, Options<ReorderBackupSchedulesData>> => {
|
||||
const mutationOptions: UseMutationOptions<ReorderBackupSchedulesResponse, DefaultError, Options<ReorderBackupSchedulesData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await reorderBackupSchedules({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,5 +14,5 @@ import type { ClientOptions as ClientOptions2 } from './types.gen';
|
|||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions2>({
|
||||
baseUrl: 'http://192.168.2.42:4096'
|
||||
baseUrl: 'http://localhost:4096'
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
@ -500,6 +500,20 @@ export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(opt
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder backup schedules by providing an array of schedule IDs in the desired order
|
||||
*/
|
||||
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ReorderBackupSchedulesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/reorder',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all notification destinations
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: 'http://192.168.2.42:4096' | (string & {});
|
||||
baseUrl: 'http://localhost:4096' | (string & {});
|
||||
};
|
||||
|
||||
export type RegisterData = {
|
||||
|
|
@ -2376,6 +2376,26 @@ export type GetMirrorCompatibilityResponses = {
|
|||
|
||||
export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses];
|
||||
|
||||
export type ReorderBackupSchedulesData = {
|
||||
body?: {
|
||||
scheduleIds: Array<number>;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/backups/reorder';
|
||||
};
|
||||
|
||||
export type ReorderBackupSchedulesResponses = {
|
||||
/**
|
||||
* Backup schedules reordered successfully
|
||||
*/
|
||||
200: {
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReorderBackupSchedulesResponse = ReorderBackupSchedulesResponses[keyof ReorderBackupSchedulesResponses];
|
||||
|
||||
export type ListNotificationDestinationsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
34
app/client/components/sortable-card.tsx
Normal file
34
app/client/components/sortable-card.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
interface SortableBackupCardProps {
|
||||
isDragging?: boolean;
|
||||
uniqueId: number;
|
||||
}
|
||||
|
||||
export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildren<SortableBackupCardProps>) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: uniqueId,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="relative group">
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 cursor-grab active:cursor-grabbing opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/50 bg-background/80 backdrop-blur-sm"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground rotate-90" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
app/client/components/ui/secret-input.tsx
Normal file
59
app/client/components/ui/secret-input.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
|
||||
export const isStoredSecretValue = (value?: string): boolean => {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:");
|
||||
};
|
||||
|
||||
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">
|
||||
|
||||
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const showAsPlaintext = useMemo(() => {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isStoredSecretValue(value);
|
||||
}, [value]);
|
||||
|
||||
const type = useMemo(() => {
|
||||
if (showAsPlaintext) {
|
||||
return "text";
|
||||
}
|
||||
return revealed ? "text" : "password";
|
||||
}, [showAsPlaintext, revealed]);
|
||||
|
||||
return (
|
||||
<div className="relative" data-secret-input>
|
||||
<Input
|
||||
{...props}
|
||||
value={value}
|
||||
type={type}
|
||||
className={cn(!showAsPlaintext && "pr-10", className)}
|
||||
/>
|
||||
{!showAsPlaintext && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
aria-label={revealed ? "Hide secret" : "Show secret"}
|
||||
>
|
||||
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
app/client/modules/backups/components/backup-card.tsx
Normal file
54
app/client/modules/backups/components/backup-card.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { BackupSchedule } from "~/client/lib/types";
|
||||
import { BackupStatusDot } from "./backup-status-dot";
|
||||
|
||||
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||
return (
|
||||
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
|
||||
<Card key={schedule.id} className="flex flex-col h-full">
|
||||
<CardHeader className="pb-3 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 w-0">
|
||||
<CalendarClock className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<CardTitle className="text-lg truncate">{schedule.name}</CardTitle>
|
||||
</div>
|
||||
<BackupStatusDot
|
||||
enabled={schedule.enabled}
|
||||
hasError={!!schedule.lastBackupError}
|
||||
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{schedule.volume.name}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-3.5 w-3.5 text-strong-accent" />
|
||||
<span className="truncate text-strong-accent">{schedule.repository.name}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Schedule</span>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last backup</span>
|
||||
<span className="font-medium">
|
||||
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Next backup</span>
|
||||
<span className="font-medium">
|
||||
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +1,28 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { BackupStatusDot } from "../components/backup-status-dot";
|
||||
import { useState, useEffect } from "react";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
import type { Route } from "./+types/backups";
|
||||
import { listBackupSchedules } from "~/client/api-client";
|
||||
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
listBackupSchedulesOptions,
|
||||
reorderBackupSchedulesMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { SortableCard } from "~/client/components/sortable-card";
|
||||
import { BackupCard } from "../components/backup-card";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Backups" }],
|
||||
|
|
@ -35,6 +50,41 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||
initialData: loaderData,
|
||||
});
|
||||
|
||||
const [items, setItems] = useState(schedules?.map((s) => s.id) ?? []);
|
||||
useEffect(() => {
|
||||
if (schedules) {
|
||||
setItems(schedules.map((s) => s.id));
|
||||
}
|
||||
}, [schedules]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
...reorderBackupSchedulesMutation(),
|
||||
});
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setItems((items) => {
|
||||
const oldIndex = items.indexOf(active.id as number);
|
||||
const newIndex = items.indexOf(over.id as number);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
reorderMutation.mutate({ body: { scheduleIds: newItems } });
|
||||
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
|
|
@ -61,64 +111,33 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const scheduleMap = new Map(schedules.map((s) => [s.id, s]));
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
|
||||
{schedules.map((schedule) => (
|
||||
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
|
||||
<Card key={schedule.id} className="flex flex-col h-full">
|
||||
<CardHeader className="pb-3 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 w-0">
|
||||
<CalendarClock className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<CardTitle className="text-lg truncate">{schedule.name}</CardTitle>
|
||||
</div>
|
||||
<BackupStatusDot
|
||||
enabled={schedule.enabled}
|
||||
hasError={!!schedule.lastBackupError}
|
||||
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{schedule.volume.name}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-3.5 w-3.5 text-strong-accent" />
|
||||
<span className="truncate text-strong-accent">{schedule.repository.name}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Schedule</span>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last backup</span>
|
||||
<span className="font-medium">
|
||||
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Next backup</span>
|
||||
<span className="font-medium">
|
||||
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/backups/create">
|
||||
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2">
|
||||
<Plus className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
|
||||
{items.map((id) => {
|
||||
const schedule = scheduleMap.get(id);
|
||||
if (!schedule) return null;
|
||||
return (
|
||||
<SortableCard uniqueId={id} key={schedule.id}>
|
||||
<BackupCard schedule={schedule} />
|
||||
</SortableCard>
|
||||
);
|
||||
})}
|
||||
<Link to="/backups/create">
|
||||
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2">
|
||||
<Plus className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { notificationConfigSchema } from "~/schemas/notifications";
|
||||
|
|
@ -213,7 +214,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -415,7 +416,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<FormItem>
|
||||
<FormLabel>App Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application token from Gotify.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
@ -510,7 +511,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Password for server authentication, if required.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
@ -567,7 +568,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application API token from your Pushover application.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
@ -627,7 +628,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<FormItem>
|
||||
<FormLabel>Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
|
||||
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Telegram bot token. Get this from BotFather when you create your bot.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
FormMessage,
|
||||
} from "../../../components/ui/form";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { SecretInput } from "../../../components/ui/secret-input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
|
|
@ -241,7 +242,11 @@ export const CreateRepositoryForm = ({
|
|||
<FormItem>
|
||||
<FormLabel>Repository Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter repository password" {...field} />
|
||||
<SecretInput
|
||||
placeholder="Enter repository password"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The password used to encrypt this repository. It will be stored securely.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
import type { RepositoryFormValues } from "../create-repository-form";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -52,7 +53,7 @@ export const AzureRepositoryForm = ({ form }: Props) => {
|
|||
<FormItem>
|
||||
<FormLabel>Account Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Azure Storage account key for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
import type { RepositoryFormValues } from "../create-repository-form";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -68,7 +69,7 @@ export const R2RepositoryForm = ({ form }: Props) => {
|
|||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
import type { RepositoryFormValues } from "../create-repository-form";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -66,7 +67,7 @@ export const RestRepositoryForm = ({ form }: Props) => {
|
|||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for REST server authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
import type { RepositoryFormValues } from "../create-repository-form";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -66,7 +67,7 @@ export const S3RepositoryForm = ({ form }: Props) => {
|
|||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>S3 secret access key for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -67,7 +68,7 @@ export const SMBForm = ({ form }: Props) => {
|
|||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for SMB authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<FormValues>;
|
||||
|
|
@ -66,7 +67,7 @@ export const WebDAVForm = ({ form }: Props) => {
|
|||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for WebDAV authentication (optional).</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
|
|||
1
app/drizzle/0022_woozy_shen.sql
Normal file
1
app/drizzle/0022_woozy_shen.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `backup_schedules_table` ADD `sort_order` integer DEFAULT 0 NOT NULL;
|
||||
831
app/drizzle/meta/0022_snapshot.json
Normal file
831
app/drizzle/meta/0022_snapshot.json
Normal file
|
|
@ -0,0 +1,831 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "11c24867-3186-4578-b8dd-cee4c48a28d1",
|
||||
"prevId": "e7c02f6c-e255-402e-9f18-d50a3fef8e4d",
|
||||
"tables": {
|
||||
"app_metadata": {
|
||||
"name": "app_metadata",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedule_mirrors_table": {
|
||||
"name": "backup_schedule_mirrors_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"schedule_id": {
|
||||
"name": "schedule_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
|
||||
},
|
||||
"last_copy_at": {
|
||||
"name": "last_copy_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_copy_status": {
|
||||
"name": "last_copy_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_copy_error": {
|
||||
"name": "last_copy_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
|
||||
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
|
||||
"columns": [
|
||||
"schedule_id",
|
||||
"repository_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
|
||||
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
|
||||
"tableFrom": "backup_schedule_mirrors_table",
|
||||
"tableTo": "backup_schedules_table",
|
||||
"columnsFrom": [
|
||||
"schedule_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedule_mirrors_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": [
|
||||
"repository_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedule_notifications_table": {
|
||||
"name": "backup_schedule_notifications_table",
|
||||
"columns": {
|
||||
"schedule_id": {
|
||||
"name": "schedule_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"destination_id": {
|
||||
"name": "destination_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notify_on_start": {
|
||||
"name": "notify_on_start",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"notify_on_success": {
|
||||
"name": "notify_on_success",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"notify_on_warning": {
|
||||
"name": "notify_on_warning",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"notify_on_failure": {
|
||||
"name": "notify_on_failure",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
|
||||
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
|
||||
"tableFrom": "backup_schedule_notifications_table",
|
||||
"tableTo": "backup_schedules_table",
|
||||
"columnsFrom": [
|
||||
"schedule_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
|
||||
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
|
||||
"tableFrom": "backup_schedule_notifications_table",
|
||||
"tableTo": "notification_destinations_table",
|
||||
"columnsFrom": [
|
||||
"destination_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
|
||||
"columns": [
|
||||
"schedule_id",
|
||||
"destination_id"
|
||||
],
|
||||
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedules_table": {
|
||||
"name": "backup_schedules_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"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": "'[]'"
|
||||
},
|
||||
"exclude_if_present": {
|
||||
"name": "exclude_if_present",
|
||||
"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
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"backup_schedules_table_name_unique": {
|
||||
"name": "backup_schedules_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"notification_destinations_table": {
|
||||
"name": "notification_destinations_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notification_destinations_table_name_unique": {
|
||||
"name": "notification_destinations_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repositories_table": {
|
||||
"name": "repositories_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"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() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"repositories_table_short_id_unique": {
|
||||
"name": "repositories_table_short_id_unique",
|
||||
"columns": [
|
||||
"short_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"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() * 1000)"
|
||||
}
|
||||
},
|
||||
"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() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"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
|
||||
},
|
||||
"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() * 1000)"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"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_short_id_unique": {
|
||||
"name": "volumes_table_short_id_unique",
|
||||
"columns": [
|
||||
"short_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +155,13 @@
|
|||
"when": 1765307881092,
|
||||
"tag": "0021_steady_viper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "6",
|
||||
"when": 1765794552191,
|
||||
"tag": "0022_woozy_shen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@ import "dotenv/config";
|
|||
|
||||
const envSchema = type({
|
||||
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
|
||||
SERVER_IP: 'string = "localhost"',
|
||||
}).pipe((s) => ({
|
||||
__prod__: s.NODE_ENV === "production",
|
||||
environment: s.NODE_ENV,
|
||||
serverIp: s.SERVER_IP,
|
||||
}));
|
||||
|
||||
const parseConfig = (env: unknown) => {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning">(),
|
||||
lastBackupError: text("last_backup_error"),
|
||||
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
||||
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
|
||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { shutdown } from "./modules/lifecycle/shutdown";
|
|||
import { REQUIRED_MIGRATIONS } from "./core/constants";
|
||||
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
|
||||
import { configExportController } from "./modules/lifecycle/config-export.controller";
|
||||
import { config } from "./core/config";
|
||||
|
||||
|
||||
export const generalDescriptor = (app: Hono) =>
|
||||
openAPIRouteHandler(app, {
|
||||
|
|
@ -29,7 +31,7 @@ export const generalDescriptor = (app: Hono) =>
|
|||
version: "1.0.0",
|
||||
description: "API for managing volumes",
|
||||
},
|
||||
servers: [{ url: "http://192.168.2.42:4096", description: "Development Server" }],
|
||||
servers: [{ url: `http://${config.serverIp}:4096`, description: "Development Server" }],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -43,13 +45,21 @@ const app = new Hono()
|
|||
.use(honoLogger())
|
||||
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
||||
.route("/api/v1/auth", authController.basePath("/api/v1"))
|
||||
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
||||
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
|
||||
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
|
||||
.route("/api/v1/notifications", notificationsController.use(requireAuth))
|
||||
.route("/api/v1/system", systemController.use(requireAuth))
|
||||
.route("/api/v1/events", eventsController.use(requireAuth))
|
||||
.route("/api/v1/config", configExportController.use(requireAuth));
|
||||
.use("/api/v1/volumes/*", requireAuth)
|
||||
.use("/api/v1/repositories/*", requireAuth)
|
||||
.use("/api/v1/backups/*", requireAuth)
|
||||
.use("/api/v1/notifications/*", requireAuth)
|
||||
.use("/api/v1/system/*", requireAuth)
|
||||
.use("/api/v1/events/*", requireAuth)
|
||||
.use("/api/v1/config/*", requireAuth)
|
||||
.route("/api/v1/volumes", volumeController)
|
||||
.route("/api/v1/repositories", repositoriesController)
|
||||
.route("/api/v1/backups", backupScheduleController)
|
||||
.route("/api/v1/notifications", notificationsController)
|
||||
.route("/api/v1/system", systemController)
|
||||
.route("/api/v1/events", eventsController)
|
||||
.route("/api/v1/config", configExportController);
|
||||
|
||||
|
||||
app.get("/api/v1/openapi.json", generalDescriptor(app));
|
||||
app.get("/api/v1/docs", scalarDescriptor);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
const run = async () => {
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
|
||||
const password = await cryptoUtils.decrypt(config.password);
|
||||
const password = await cryptoUtils.resolveSecret(config.password);
|
||||
|
||||
const source = `//${config.server}/${config.share}`;
|
||||
const options = [
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
|
||||
|
||||
if (config.username && config.password) {
|
||||
const password = await cryptoUtils.decrypt(config.password);
|
||||
const password = await cryptoUtils.resolveSecret(config.password);
|
||||
const secretsFile = "/etc/davfs2/secrets";
|
||||
const secretsContent = `${source} ${config.username} ${password}\n`;
|
||||
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
updateScheduleMirrorsDto,
|
||||
updateScheduleMirrorsBody,
|
||||
getMirrorCompatibilityDto,
|
||||
reorderBackupSchedulesDto,
|
||||
reorderBackupSchedulesBody,
|
||||
type CreateBackupScheduleDto,
|
||||
type DeleteBackupScheduleDto,
|
||||
type GetBackupScheduleDto,
|
||||
|
|
@ -28,6 +30,7 @@ import {
|
|||
type GetScheduleMirrorsDto,
|
||||
type UpdateScheduleMirrorsDto,
|
||||
type GetMirrorCompatibilityDto,
|
||||
type ReorderBackupSchedulesDto,
|
||||
} from "./backups.dto";
|
||||
import { backupsService } from "./backups.service";
|
||||
import {
|
||||
|
|
@ -139,4 +142,11 @@ export const backupScheduleController = new Hono()
|
|||
const compatibility = await backupsService.getMirrorCompatibility(scheduleId);
|
||||
|
||||
return c.json<GetMirrorCompatibilityDto>(compatibility, 200);
|
||||
})
|
||||
.post("/reorder", reorderBackupSchedulesDto, validator("json", reorderBackupSchedulesBody), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
await backupsService.reorderSchedules(body.scheduleIds);
|
||||
|
||||
return c.json<ReorderBackupSchedulesDto>({ success: true }, 200);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -367,3 +367,34 @@ export const getMirrorCompatibilityDto = describeRoute({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reorder backup schedules
|
||||
*/
|
||||
export const reorderBackupSchedulesBody = type({
|
||||
scheduleIds: "number[]",
|
||||
});
|
||||
|
||||
export type ReorderBackupSchedulesBody = typeof reorderBackupSchedulesBody.infer;
|
||||
|
||||
export const reorderBackupSchedulesResponse = type({
|
||||
success: "boolean",
|
||||
});
|
||||
|
||||
export type ReorderBackupSchedulesDto = typeof reorderBackupSchedulesResponse.infer;
|
||||
|
||||
export const reorderBackupSchedulesDto = describeRoute({
|
||||
description: "Reorder backup schedules by providing an array of schedule IDs in the desired order",
|
||||
operationId: "reorderBackupSchedules",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backup schedules reordered successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(reorderBackupSchedulesResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { and, asc, eq, ne } from "drizzle-orm";
|
||||
import cron from "node-cron";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||
|
|
@ -38,6 +38,7 @@ const listSchedules = async () => {
|
|||
volume: true,
|
||||
repository: true,
|
||||
},
|
||||
orderBy: [asc(backupSchedulesTable.sortOrder), asc(backupSchedulesTable.id)],
|
||||
});
|
||||
return schedules;
|
||||
};
|
||||
|
|
@ -637,6 +638,36 @@ const getMirrorCompatibility = async (scheduleId: number) => {
|
|||
return compatibility;
|
||||
};
|
||||
|
||||
const reorderSchedules = async (scheduleIds: number[]) => {
|
||||
const uniqueIds = new Set(scheduleIds);
|
||||
if (uniqueIds.size !== scheduleIds.length) {
|
||||
throw new BadRequestError("Duplicate schedule IDs in reorder request");
|
||||
}
|
||||
|
||||
const existingSchedules = await db.query.backupSchedulesTable.findMany({
|
||||
columns: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existingSchedules.map((s) => s.id));
|
||||
|
||||
for (const id of scheduleIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
throw new NotFoundError(`Backup schedule with ID ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
scheduleIds.map((scheduleId, index) =>
|
||||
tx
|
||||
.update(backupSchedulesTable)
|
||||
.set({ sortOrder: index, updatedAt: now })
|
||||
.where(eq(backupSchedulesTable.id, scheduleId)),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const backupsService = {
|
||||
listSchedules,
|
||||
getSchedule,
|
||||
|
|
@ -651,4 +682,5 @@ export const backupsService = {
|
|||
getMirrors,
|
||||
updateMirrors,
|
||||
getMirrorCompatibility,
|
||||
reorderSchedules,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,42 +38,42 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
|
|||
case "email":
|
||||
return {
|
||||
...config,
|
||||
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
|
||||
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
|
||||
};
|
||||
case "slack":
|
||||
return {
|
||||
...config,
|
||||
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
|
||||
webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl),
|
||||
};
|
||||
case "discord":
|
||||
return {
|
||||
...config,
|
||||
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
|
||||
webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl),
|
||||
};
|
||||
case "gotify":
|
||||
return {
|
||||
...config,
|
||||
token: await cryptoUtils.encrypt(config.token),
|
||||
token: await cryptoUtils.sealSecret(config.token),
|
||||
};
|
||||
case "ntfy":
|
||||
return {
|
||||
...config,
|
||||
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
|
||||
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
|
||||
};
|
||||
case "pushover":
|
||||
return {
|
||||
...config,
|
||||
apiToken: await cryptoUtils.encrypt(config.apiToken),
|
||||
apiToken: await cryptoUtils.sealSecret(config.apiToken),
|
||||
};
|
||||
case "telegram":
|
||||
return {
|
||||
...config,
|
||||
botToken: await cryptoUtils.encrypt(config.botToken),
|
||||
botToken: await cryptoUtils.sealSecret(config.botToken),
|
||||
};
|
||||
case "custom":
|
||||
return {
|
||||
...config,
|
||||
shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl),
|
||||
shoutrrrUrl: await cryptoUtils.sealSecret(config.shoutrrrUrl),
|
||||
};
|
||||
default:
|
||||
return config;
|
||||
|
|
@ -85,42 +85,42 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
|
|||
case "email":
|
||||
return {
|
||||
...config,
|
||||
password: config.password ? await cryptoUtils.decrypt(config.password) : undefined,
|
||||
password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
|
||||
};
|
||||
case "slack":
|
||||
return {
|
||||
...config,
|
||||
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
|
||||
webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
|
||||
};
|
||||
case "discord":
|
||||
return {
|
||||
...config,
|
||||
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
|
||||
webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
|
||||
};
|
||||
case "gotify":
|
||||
return {
|
||||
...config,
|
||||
token: await cryptoUtils.decrypt(config.token),
|
||||
token: await cryptoUtils.resolveSecret(config.token),
|
||||
};
|
||||
case "ntfy":
|
||||
return {
|
||||
...config,
|
||||
password: config.password ? await cryptoUtils.decrypt(config.password) : undefined,
|
||||
password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
|
||||
};
|
||||
case "pushover":
|
||||
return {
|
||||
...config,
|
||||
apiToken: await cryptoUtils.decrypt(config.apiToken),
|
||||
apiToken: await cryptoUtils.resolveSecret(config.apiToken),
|
||||
};
|
||||
case "telegram":
|
||||
return {
|
||||
...config,
|
||||
botToken: await cryptoUtils.decrypt(config.botToken),
|
||||
botToken: await cryptoUtils.resolveSecret(config.botToken),
|
||||
};
|
||||
case "custom":
|
||||
return {
|
||||
...config,
|
||||
shoutrrrUrl: await cryptoUtils.decrypt(config.shoutrrrUrl),
|
||||
shoutrrrUrl: await cryptoUtils.resolveSecret(config.shoutrrrUrl),
|
||||
};
|
||||
default:
|
||||
return config;
|
||||
|
|
|
|||
|
|
@ -20,31 +20,31 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||
const encryptedConfig: Record<string, string | boolean | number> = { ...config };
|
||||
|
||||
if (config.customPassword) {
|
||||
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
|
||||
encryptedConfig.customPassword = await cryptoUtils.sealSecret(config.customPassword);
|
||||
}
|
||||
|
||||
switch (config.backend) {
|
||||
case "s3":
|
||||
case "r2":
|
||||
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
|
||||
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
|
||||
encryptedConfig.accessKeyId = await cryptoUtils.sealSecret(config.accessKeyId);
|
||||
encryptedConfig.secretAccessKey = await cryptoUtils.sealSecret(config.secretAccessKey);
|
||||
break;
|
||||
case "gcs":
|
||||
encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson);
|
||||
encryptedConfig.credentialsJson = await cryptoUtils.sealSecret(config.credentialsJson);
|
||||
break;
|
||||
case "azure":
|
||||
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
|
||||
encryptedConfig.accountKey = await cryptoUtils.sealSecret(config.accountKey);
|
||||
break;
|
||||
case "rest":
|
||||
if (config.username) {
|
||||
encryptedConfig.username = await cryptoUtils.encrypt(config.username);
|
||||
encryptedConfig.username = await cryptoUtils.sealSecret(config.username);
|
||||
}
|
||||
if (config.password) {
|
||||
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
||||
encryptedConfig.password = await cryptoUtils.sealSecret(config.password);
|
||||
}
|
||||
break;
|
||||
case "sftp":
|
||||
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
|
||||
encryptedConfig.privateKey = await cryptoUtils.sealSecret(config.privateKey);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,12 +23,12 @@ async function encryptSensitiveFields(config: BackendConfig): Promise<BackendCon
|
|||
case "smb":
|
||||
return {
|
||||
...config,
|
||||
password: await cryptoUtils.encrypt(config.password),
|
||||
password: await cryptoUtils.sealSecret(config.password),
|
||||
};
|
||||
case "webdav":
|
||||
return {
|
||||
...config,
|
||||
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
|
||||
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
|
||||
};
|
||||
default:
|
||||
return config;
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ export const hasCompatibleCredentials = async (
|
|||
(config1.backend === "s3" || config1.backend === "r2") &&
|
||||
(config2.backend === "s3" || config2.backend === "r2")
|
||||
) {
|
||||
const accessKey1 = await cryptoUtils.decrypt(config1.accessKeyId);
|
||||
const secretKey1 = await cryptoUtils.decrypt(config1.secretAccessKey);
|
||||
const accessKey1 = await cryptoUtils.resolveSecret(config1.accessKeyId);
|
||||
const secretKey1 = await cryptoUtils.resolveSecret(config1.secretAccessKey);
|
||||
|
||||
const accessKey2 = await cryptoUtils.decrypt(config2.accessKeyId);
|
||||
const secretKey2 = await cryptoUtils.decrypt(config2.secretAccessKey);
|
||||
const accessKey2 = await cryptoUtils.resolveSecret(config2.accessKeyId);
|
||||
const secretKey2 = await cryptoUtils.resolveSecret(config2.secretAccessKey);
|
||||
|
||||
return accessKey1 === accessKey2 && secretKey1 === secretKey2;
|
||||
}
|
||||
|
|
@ -53,8 +53,8 @@ export const hasCompatibleCredentials = async (
|
|||
}
|
||||
case "gcs": {
|
||||
if (config1.backend === "gcs" && config2.backend === "gcs") {
|
||||
const credentials1 = await cryptoUtils.decrypt(config1.credentialsJson);
|
||||
const credentials2 = await cryptoUtils.decrypt(config2.credentialsJson);
|
||||
const credentials1 = await cryptoUtils.resolveSecret(config1.credentialsJson);
|
||||
const credentials2 = await cryptoUtils.resolveSecret(config2.credentialsJson);
|
||||
|
||||
return credentials1 === credentials2 && config1.projectId === config2.projectId;
|
||||
}
|
||||
|
|
@ -62,8 +62,8 @@ export const hasCompatibleCredentials = async (
|
|||
}
|
||||
case "azure": {
|
||||
if (config1.backend === "azure" && config2.backend === "azure") {
|
||||
const config1Accountkey = await cryptoUtils.decrypt(config1.accountKey);
|
||||
const config2Accountkey = await cryptoUtils.decrypt(config2.accountKey);
|
||||
const config1Accountkey = await cryptoUtils.resolveSecret(config1.accountKey);
|
||||
const config2Accountkey = await cryptoUtils.resolveSecret(config2.accountKey);
|
||||
|
||||
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey;
|
||||
}
|
||||
|
|
@ -75,10 +75,10 @@ export const hasCompatibleCredentials = async (
|
|||
return true;
|
||||
}
|
||||
|
||||
const config1Username = await cryptoUtils.decrypt(config1.username || "");
|
||||
const config1Password = await cryptoUtils.decrypt(config1.password || "");
|
||||
const config2Username = await cryptoUtils.decrypt(config2.username || "");
|
||||
const config2Password = await cryptoUtils.decrypt(config2.password || "");
|
||||
const config1Username = await cryptoUtils.resolveSecret(config1.username || "");
|
||||
const config1Password = await cryptoUtils.resolveSecret(config1.password || "");
|
||||
const config2Username = await cryptoUtils.resolveSecret(config2.username || "");
|
||||
const config2Password = await cryptoUtils.resolveSecret(config2.password || "");
|
||||
|
||||
return config1Username === config2Username && config1Password === config2Password;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||
import { isNodeJSErrnoException } from "./fs";
|
||||
|
||||
const algorithm = "aes-256-gcm" as const;
|
||||
const keyLength = 32;
|
||||
const encryptionPrefix = "encv1";
|
||||
const encryptionPrefix = "encv1:";
|
||||
|
||||
const envSecretPrefix = "env://";
|
||||
const fileSecretPrefix = "file://";
|
||||
|
||||
/**
|
||||
* Checks if a given string is encrypted by looking for the encryption prefix.
|
||||
|
|
@ -12,6 +18,68 @@ const isEncrypted = (val?: string): boolean => {
|
|||
return typeof val === "string" && val.startsWith(encryptionPrefix);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string looks like a supported secret reference.
|
||||
* - env://VAR_NAME -> reads process.env.VAR_NAME
|
||||
* - file://name -> reads a file /run/secrets/name
|
||||
*/
|
||||
const isSecretReference = (val?: string): boolean => {
|
||||
return typeof val === "string" && (val.startsWith(envSecretPrefix) || val.startsWith(fileSecretPrefix));
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves an environment variable secret reference.
|
||||
*/
|
||||
const resolveEnvSecret = (ref: string): string => {
|
||||
const name = ref.slice(envSecretPrefix.length);
|
||||
if (!name) {
|
||||
throw new Error("env:// reference is missing variable name");
|
||||
}
|
||||
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Environment variable not set: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a file-based secret reference.
|
||||
* Reads the secret from /run/secrets/{name}
|
||||
*/
|
||||
const resolveFileSecret = async (ref: string): Promise<string> => {
|
||||
const secretName = ref.slice(fileSecretPrefix.length);
|
||||
if (!secretName) {
|
||||
throw new Error("file:// reference is missing secret name");
|
||||
}
|
||||
|
||||
const normalizedName = secretName.replace(/^\/+/, "");
|
||||
if (!normalizedName) {
|
||||
throw new Error("file:// reference is missing secret name");
|
||||
}
|
||||
if (normalizedName.includes("\0") || normalizedName.includes("/") || normalizedName.includes("\\")) {
|
||||
throw new Error("Invalid secret reference: secret name must be a single path segment");
|
||||
}
|
||||
|
||||
const resolvedPath = path.join("/run/secrets", normalizedName);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(resolvedPath, "utf-8");
|
||||
return content.trimEnd();
|
||||
} catch (error) {
|
||||
if (isNodeJSErrnoException(error)) {
|
||||
if (error.code === "ENOENT") {
|
||||
throw new Error(`Secret file not found: ${resolvedPath}`);
|
||||
}
|
||||
if (error.code === "EACCES") {
|
||||
throw new Error(`Permission denied reading secret file: ${resolvedPath}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to read secret file ${resolvedPath}: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a string, encrypts it using a randomly generated salt.
|
||||
* Returns the input unchanged if it's empty or already encrypted.
|
||||
|
|
@ -35,7 +103,7 @@ const encrypt = async (data: string) => {
|
|||
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${encryptionPrefix}:${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`;
|
||||
return `${encryptionPrefix}${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -68,8 +136,54 @@ const decrypt = async (encryptedData: string) => {
|
|||
return decrypted.toString();
|
||||
};
|
||||
|
||||
export const cryptoUtils = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
isEncrypted,
|
||||
/**
|
||||
* Resolves secret references and encrypted database values.
|
||||
*
|
||||
* - encv1:... -> decrypt
|
||||
* - env://VAR -> read process.env.VAR
|
||||
* - file://name -> read /run/secrets/name
|
||||
* - otherwise returns value unchanged
|
||||
*/
|
||||
const resolveSecret = async (value: string): Promise<string> => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isEncrypted(value)) {
|
||||
return decrypt(value);
|
||||
}
|
||||
|
||||
if (value.startsWith(envSecretPrefix)) {
|
||||
return resolveEnvSecret(value);
|
||||
}
|
||||
|
||||
if (value.startsWith(fileSecretPrefix)) {
|
||||
return resolveFileSecret(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares a secret value for storage.
|
||||
*
|
||||
* - env://... and file://... are stored as-is (references)
|
||||
* - encv1:... is stored as-is (already encrypted)
|
||||
* - otherwise encrypt before storing
|
||||
*/
|
||||
const sealSecret = async (value: string): Promise<string> => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isEncrypted(value) || isSecretReference(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return encrypt(value);
|
||||
};
|
||||
|
||||
export const cryptoUtils = {
|
||||
resolveSecret,
|
||||
sealSecret,
|
||||
};
|
||||
|
|
|
|||
8
app/server/utils/fs.ts
Normal file
8
app/server/utils/fs.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const isNodeJSErrnoException = (error: unknown): error is NodeJS.ErrnoException => {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
typeof (error as NodeJS.ErrnoException).code === "string"
|
||||
);
|
||||
};
|
||||
|
|
@ -106,7 +106,7 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||
};
|
||||
|
||||
if (config.isExistingRepository && config.customPassword) {
|
||||
const decryptedPassword = await cryptoUtils.decrypt(config.customPassword);
|
||||
const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword);
|
||||
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
|
||||
|
||||
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
||||
|
|
@ -117,17 +117,17 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||
|
||||
switch (config.backend) {
|
||||
case "s3":
|
||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
|
||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
|
||||
break;
|
||||
case "r2":
|
||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
|
||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
|
||||
env.AWS_REGION = "auto";
|
||||
env.AWS_S3_FORCE_PATH_STYLE = "true";
|
||||
break;
|
||||
case "gcs": {
|
||||
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
|
||||
const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson);
|
||||
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
|
||||
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
|
||||
env.GOOGLE_PROJECT_ID = config.projectId;
|
||||
|
|
@ -136,7 +136,7 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||
}
|
||||
case "azure": {
|
||||
env.AZURE_ACCOUNT_NAME = config.accountName;
|
||||
env.AZURE_ACCOUNT_KEY = await cryptoUtils.decrypt(config.accountKey);
|
||||
env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey);
|
||||
if (config.endpointSuffix) {
|
||||
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
|
||||
}
|
||||
|
|
@ -144,15 +144,15 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||
}
|
||||
case "rest": {
|
||||
if (config.username) {
|
||||
env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username);
|
||||
env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username);
|
||||
}
|
||||
if (config.password) {
|
||||
env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password);
|
||||
env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sftp": {
|
||||
const decryptedKey = await cryptoUtils.decrypt(config.privateKey);
|
||||
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
|
||||
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
|
||||
|
||||
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
||||
|
|
|
|||
11
bun.lock
11
bun.lock
|
|
@ -4,6 +4,9 @@
|
|||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hono/standard-validator": "^0.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
|
@ -156,6 +159,14 @@
|
|||
|
||||
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
||||
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ services:
|
|||
- SYS_ADMIN
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
# - SMB_PASSWORD=secret
|
||||
ports:
|
||||
- "4096:4096"
|
||||
# secrets:
|
||||
# - smb-password
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
|
|
@ -42,3 +45,7 @@ services:
|
|||
- /run/docker/plugins:/run/docker/plugins
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ~/.config/rclone:/root/.config/rclone
|
||||
|
||||
# secrets:
|
||||
# smb-password:
|
||||
# file: ./smb-password.txt
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts";
|
||||
import { config } from "./app/server/core/config.js";
|
||||
|
||||
export default defineConfig({
|
||||
input: "http://192.168.2.42:4096/api/v1/openapi.json",
|
||||
input: `http://${config.serverIp}:4096/api/v1/openapi.json`,
|
||||
output: {
|
||||
path: "./app/client/api-client",
|
||||
format: "biome",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
"studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hono/standard-validator": "^0.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
|
|
|||
Loading…
Reference in a new issue