Multi users (#381)
* feat(db): add support for multiple users and organizations * feat: backfill entities with new organization id * refactor: filter all backend queries to surface only organization specific entities * refactor: each org has its own restic password * test: ensure organization is created * chore: pr feedbacks * refactor: filter by org id in all places * refactor: download restic password from stored db password * refactor(navigation): use volume id in urls instead of name * feat: disable registrations * refactor(auth): bubble up auth error to hono * refactor: use async local storage for cleaner context sharing * refactor: enable user registration vs disabling it * test: multi-org isolation * chore: final cleanup
This commit is contained in:
parent
44b1a27dd1
commit
451aed8983
80 changed files with 16874 additions and 419 deletions
|
|
@ -1,6 +1,6 @@
|
|||
SERVER_IP=localhost
|
||||
DATABASE_URL=./data/zerobyte.db
|
||||
RESTIC_PASS_FILE=./data/restic.pass
|
||||
RESTIC_CACHE_DIR=./data/restic/cache
|
||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||
APP_SECRET=<openssl rand -hex 32>
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
DATABASE_URL=:memory:
|
||||
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69
|
||||
|
|
|
|||
12
AGENTS.md
12
AGENTS.md
|
|
@ -75,14 +75,13 @@ bun run gen:api-client
|
|||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format and lint (Biome)
|
||||
bunx biome check --write .
|
||||
# Format and lint
|
||||
|
||||
# Format only
|
||||
bunx biome format --write .
|
||||
# Format
|
||||
bunx oxfmt format --write <path>
|
||||
|
||||
# Lint only
|
||||
bunx biome lint .
|
||||
# Lint
|
||||
bun run lint
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
|
@ -186,7 +185,6 @@ Zerobyte is a wrapper around Restic for backup operations. Key integration point
|
|||
|
||||
- `buildRepoUrl()` - Constructs repository URLs for different backends
|
||||
- `buildEnv()` - Sets environment variables (credentials, cache dir)
|
||||
- `ensurePassfile()` - Manages encryption password file
|
||||
- Type-safe parsing of Restic JSON output using ArkType schemas
|
||||
|
||||
**Rclone Integration** (`app/server/modules/repositories/`):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
getBackupScheduleForVolume,
|
||||
getMirrorCompatibility,
|
||||
getNotificationDestination,
|
||||
getRegistrationStatus,
|
||||
getRepository,
|
||||
getScheduleMirrors,
|
||||
getScheduleNotifications,
|
||||
|
|
@ -44,6 +45,7 @@ import {
|
|||
restoreSnapshot,
|
||||
runBackupNow,
|
||||
runForget,
|
||||
setRegistrationStatus,
|
||||
stopBackup,
|
||||
tagSnapshots,
|
||||
testConnection,
|
||||
|
|
@ -91,6 +93,8 @@ import type {
|
|||
GetMirrorCompatibilityResponse,
|
||||
GetNotificationDestinationData,
|
||||
GetNotificationDestinationResponse,
|
||||
GetRegistrationStatusData,
|
||||
GetRegistrationStatusResponse,
|
||||
GetRepositoryData,
|
||||
GetRepositoryResponse,
|
||||
GetScheduleMirrorsData,
|
||||
|
|
@ -135,6 +139,8 @@ import type {
|
|||
RunBackupNowResponse,
|
||||
RunForgetData,
|
||||
RunForgetResponse,
|
||||
SetRegistrationStatusData,
|
||||
SetRegistrationStatusResponse,
|
||||
StopBackupData,
|
||||
StopBackupResponse,
|
||||
TagSnapshotsData,
|
||||
|
|
@ -1259,8 +1265,56 @@ export const getUpdatesOptions = (options?: Options<GetUpdatesData>) =>
|
|||
queryKey: getUpdatesQueryKey(options),
|
||||
});
|
||||
|
||||
export const getRegistrationStatusQueryKey = (options?: Options<GetRegistrationStatusData>) =>
|
||||
createQueryKey("getRegistrationStatus", options);
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
* Get the current registration status for new users
|
||||
*/
|
||||
export const getRegistrationStatusOptions = (options?: Options<GetRegistrationStatusData>) =>
|
||||
queryOptions<
|
||||
GetRegistrationStatusResponse,
|
||||
DefaultError,
|
||||
GetRegistrationStatusResponse,
|
||||
ReturnType<typeof getRegistrationStatusQueryKey>
|
||||
>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getRegistrationStatus({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getRegistrationStatusQueryKey(options),
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the registration status for new users. Requires global admin role.
|
||||
*/
|
||||
export const setRegistrationStatusMutation = (
|
||||
options?: Partial<Options<SetRegistrationStatusData>>,
|
||||
): UseMutationOptions<SetRegistrationStatusResponse, DefaultError, Options<SetRegistrationStatusData>> => {
|
||||
const mutationOptions: UseMutationOptions<
|
||||
SetRegistrationStatusResponse,
|
||||
DefaultError,
|
||||
Options<SetRegistrationStatusData>
|
||||
> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await setRegistrationStatus({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
|
||||
*/
|
||||
export const downloadResticPasswordMutation = (
|
||||
options?: Partial<Options<DownloadResticPasswordData>>,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export {
|
|||
getBackupScheduleForVolume,
|
||||
getMirrorCompatibility,
|
||||
getNotificationDestination,
|
||||
getRegistrationStatus,
|
||||
getRepository,
|
||||
getScheduleMirrors,
|
||||
getScheduleNotifications,
|
||||
|
|
@ -41,6 +42,7 @@ export {
|
|||
restoreSnapshot,
|
||||
runBackupNow,
|
||||
runForget,
|
||||
setRegistrationStatus,
|
||||
stopBackup,
|
||||
tagSnapshots,
|
||||
testConnection,
|
||||
|
|
@ -108,6 +110,9 @@ export type {
|
|||
GetNotificationDestinationErrors,
|
||||
GetNotificationDestinationResponse,
|
||||
GetNotificationDestinationResponses,
|
||||
GetRegistrationStatusData,
|
||||
GetRegistrationStatusResponse,
|
||||
GetRegistrationStatusResponses,
|
||||
GetRepositoryData,
|
||||
GetRepositoryResponse,
|
||||
GetRepositoryResponses,
|
||||
|
|
@ -176,6 +181,9 @@ export type {
|
|||
RunForgetData,
|
||||
RunForgetResponse,
|
||||
RunForgetResponses,
|
||||
SetRegistrationStatusData,
|
||||
SetRegistrationStatusResponse,
|
||||
SetRegistrationStatusResponses,
|
||||
StopBackupData,
|
||||
StopBackupErrors,
|
||||
StopBackupResponse,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import type {
|
|||
GetNotificationDestinationData,
|
||||
GetNotificationDestinationErrors,
|
||||
GetNotificationDestinationResponses,
|
||||
GetRegistrationStatusData,
|
||||
GetRegistrationStatusResponses,
|
||||
GetRepositoryData,
|
||||
GetRepositoryResponses,
|
||||
GetScheduleMirrorsData,
|
||||
|
|
@ -85,6 +87,8 @@ import type {
|
|||
RunBackupNowResponses,
|
||||
RunForgetData,
|
||||
RunForgetResponses,
|
||||
SetRegistrationStatusData,
|
||||
SetRegistrationStatusResponses,
|
||||
StopBackupData,
|
||||
StopBackupErrors,
|
||||
StopBackupResponses,
|
||||
|
|
@ -179,7 +183,7 @@ export const testConnection = <ThrowOnError extends boolean = false>(
|
|||
*/
|
||||
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) =>
|
||||
(options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}",
|
||||
url: "/api/v1/volumes/{id}",
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -188,7 +192,7 @@ export const deleteVolume = <ThrowOnError extends boolean = false>(options: Opti
|
|||
*/
|
||||
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}",
|
||||
url: "/api/v1/volumes/{id}",
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -197,7 +201,7 @@ export const getVolume = <ThrowOnError extends boolean = false>(options: Options
|
|||
*/
|
||||
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) =>
|
||||
(options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}",
|
||||
url: "/api/v1/volumes/{id}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -210,7 +214,7 @@ export const updateVolume = <ThrowOnError extends boolean = false>(options: Opti
|
|||
*/
|
||||
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/mount",
|
||||
url: "/api/v1/volumes/{id}/mount",
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -221,7 +225,7 @@ export const unmountVolume = <ThrowOnError extends boolean = false>(
|
|||
options: Options<UnmountVolumeData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/unmount",
|
||||
url: "/api/v1/volumes/{id}/unmount",
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -232,7 +236,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
|
|||
options: Options<HealthCheckVolumeData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/health-check",
|
||||
url: "/api/v1/volumes/{id}/health-check",
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -241,7 +245,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
|
|||
*/
|
||||
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/files",
|
||||
url: "/api/v1/volumes/{id}/files",
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -708,7 +712,33 @@ export const getUpdates = <ThrowOnError extends boolean = false>(options?: Optio
|
|||
});
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
* Get the current registration status for new users
|
||||
*/
|
||||
export const getRegistrationStatus = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<GetRegistrationStatusData, ThrowOnError>,
|
||||
) =>
|
||||
(options?.client ?? client).get<GetRegistrationStatusResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/system/registration-status",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the registration status for new users. Requires global admin role.
|
||||
*/
|
||||
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<SetRegistrationStatusData, ThrowOnError>,
|
||||
) =>
|
||||
(options?.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/system/registration-status",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
|
||||
*/
|
||||
export const downloadResticPassword = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<DownloadResticPasswordData, ThrowOnError>,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@ import { useFileBrowser } from "../hooks/use-file-browser";
|
|||
import { parseError } from "../lib/errors";
|
||||
|
||||
type VolumeFileBrowserProps = {
|
||||
volumeName: string;
|
||||
volumeId: string;
|
||||
enabled?: boolean;
|
||||
withCheckboxes?: boolean;
|
||||
selectedPaths?: Set<string>;
|
||||
|
|
@ -18,7 +18,7 @@ type VolumeFileBrowserProps = {
|
|||
};
|
||||
|
||||
export const VolumeFileBrowser = ({
|
||||
volumeName,
|
||||
volumeId,
|
||||
enabled = true,
|
||||
withCheckboxes = false,
|
||||
selectedPaths,
|
||||
|
|
@ -31,7 +31,7 @@ export const VolumeFileBrowser = ({
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
...listFilesOptions({ path: { name: volumeName } }),
|
||||
...listFilesOptions({ path: { id: volumeId } }),
|
||||
enabled,
|
||||
});
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ export const VolumeFileBrowser = ({
|
|||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
path: { id: volumeId },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
|
@ -49,7 +49,7 @@ export const VolumeFileBrowser = ({
|
|||
prefetchFolder: (path) => {
|
||||
void queryClient.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
path: { id: volumeId },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { createAuthClient } from "better-auth/react";
|
||||
import { twoFactorClient, usernameClient } from "better-auth/client/plugins";
|
||||
import { twoFactorClient, usernameClient, adminClient, organizationClient } from "better-auth/client/plugins";
|
||||
import { inferAdditionalFields } from "better-auth/client/plugins";
|
||||
import type { auth } from "~/lib/auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [inferAdditionalFields<typeof auth>(), usernameClient(), twoFactorClient()],
|
||||
plugins: [
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
usernameClient(),
|
||||
adminClient(),
|
||||
organizationClient(),
|
||||
twoFactorClient(),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
|
||||
export const REGISTRATION_ENABLED_KEY = "registrations_enabled";
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ export default function OnboardingPage() {
|
|||
void navigate("/download-recovery-key");
|
||||
} else if (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to create admin user", { description: error.message });
|
||||
const errorMessage = error.message ?? "Unknown error";
|
||||
toast.error("Failed to create admin user", { description: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
<CardContent>
|
||||
<VolumeFileBrowser
|
||||
key={volume.id}
|
||||
volumeName={volume.name}
|
||||
volumeId={volume.shortId}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
withCheckboxes={true}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||
<div>
|
||||
<CardTitle>{schedule.name}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
<Link to={`/volumes/${schedule.volume.name}`} className="hover:underline">
|
||||
<Link to={`/volumes/${schedule.volume.shortId}`} className="hover:underline">
|
||||
<HardDrive className="inline h-4 w-4 mr-2" />
|
||||
<span>{schedule.volume.name}</span>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||
<span className="text-muted-foreground">Volume:</span>
|
||||
<p>
|
||||
<Link
|
||||
to={`/volumes/${backupSchedule?.volume.name}`}
|
||||
to={`/volumes/${backupSchedule?.volume.shortId}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{backupSchedule?.volume.name}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User, X } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User, X, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
downloadResticPasswordMutation,
|
||||
setRegistrationStatusMutation,
|
||||
getRegistrationStatusOptions,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
|
|
@ -17,6 +21,7 @@ import {
|
|||
} from "~/client/components/ui/dialog";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Switch } from "~/client/components/ui/switch";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { appContext } from "~/context";
|
||||
import { TwoFactorSection } from "../components/two-factor-section";
|
||||
|
|
@ -50,6 +55,25 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = loaderData.user?.role === "admin";
|
||||
|
||||
const registrationStatusQuery = useQuery({
|
||||
...getRegistrationStatusOptions(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const updateRegistrationStatusMutation = useMutation({
|
||||
...setRegistrationStatusMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Registration settings updated");
|
||||
void registrationStatusQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update registration settings", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut({
|
||||
|
|
@ -145,6 +169,33 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
{isAdmin && (
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="size-5" />
|
||||
System Settings
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Manage system-wide settings</CardDescription>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enable-registrations" className="text-base">
|
||||
Enable new user registrations
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">When enabled, new users can sign up</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-registrations"
|
||||
checked={registrationStatusQuery.data?.enabled ?? false}
|
||||
onCheckedChange={(checked) => updateRegistrationStatusMutation.mutate({ body: { enabled: checked } })}
|
||||
disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-5" />
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
|||
<OnOff
|
||||
isOn={volume.autoRemount}
|
||||
toggle={() =>
|
||||
toggleAutoRemount.mutate({ path: { name: volume.name }, body: { autoRemount: !volume.autoRemount } })
|
||||
toggleAutoRemount.mutate({ path: { id: volume.shortId }, body: { autoRemount: !volume.autoRemount } })
|
||||
}
|
||||
disabled={toggleAutoRemount.isPending}
|
||||
enabledLabel="Enabled"
|
||||
|
|
@ -74,7 +74,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
|||
variant="outline"
|
||||
className="mt-4"
|
||||
loading={healthcheck.isPending}
|
||||
onClick={() => healthcheck.mutate({ path: { name: volume.name } })}
|
||||
onClick={() => healthcheck.mutate({ path: { id: volume.shortId } })}
|
||||
>
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
Run Health Check
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function CreateVolume() {
|
|||
...createVolumeMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Volume created successfully");
|
||||
void navigate(`/volumes/${data.name}`);
|
||||
void navigate(`/volumes/${data.shortId}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -41,12 +41,12 @@ const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "
|
|||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
|
||||
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.id }],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - ${params.name}` },
|
||||
{ title: `Zerobyte - ${params.id}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
|
|
@ -55,19 +55,19 @@ export function meta({ params }: Route.MetaArgs) {
|
|||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const volume = await getVolume({ path: { name: params.name } });
|
||||
const volume = await getVolume({ path: { id: params.id } });
|
||||
if (volume.data) return volume.data;
|
||||
};
|
||||
|
||||
export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||
...getVolumeOptions({ path: { id: id ?? "" } }),
|
||||
initialData: loaderData,
|
||||
});
|
||||
|
||||
|
|
@ -110,10 +110,10 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteVol.mutate({ path: { name: name ?? "" } });
|
||||
deleteVol.mutate({ path: { id: id ?? "" } });
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
if (!id) {
|
||||
return <div>Volume not found</div>;
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => mountVol.mutate({ path: { name } })}
|
||||
onClick={() => mountVol.mutate({ path: { id } })}
|
||||
loading={mountVol.isPending}
|
||||
className={cn({ hidden: volume.status === "mounted" })}
|
||||
>
|
||||
|
|
@ -148,7 +148,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unmountVol.mutate({ path: { name } })}
|
||||
onClick={() => unmountVol.mutate({ path: { id } })}
|
||||
loading={unmountVol.isPending}
|
||||
className={cn({ hidden: volume.status !== "mounted" })}
|
||||
>
|
||||
|
|
@ -178,7 +178,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the volume <strong>{name}</strong>? This action cannot be undone.
|
||||
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||
<TableRow
|
||||
key={volume.name}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/volumes/${volume.name}`)}
|
||||
onClick={() => navigate(`/volumes/${volume.shortId}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const FilesTabContent = ({ volume }: Props) => {
|
|||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col">
|
||||
<VolumeFileBrowser
|
||||
volumeName={volume.name}
|
||||
volumeId={volume.shortId}
|
||||
enabled={volume.status === "mounted"}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2"
|
||||
emptyMessage="This volume is empty."
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
setPendingValues(null);
|
||||
|
||||
if (data.name !== volume.name) {
|
||||
void navigate(`/volumes/${data.name}`);
|
||||
void navigate(`/volumes/${data.shortId}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
@ -58,7 +58,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
const confirmUpdate = () => {
|
||||
if (pendingValues) {
|
||||
updateMutation.mutate({
|
||||
path: { name: volume.name },
|
||||
path: { id: volume.shortId },
|
||||
body: { name: pendingValues.name, config: pendingValues },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ type User = {
|
|||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
twoFactorEnabled?: boolean | null;
|
||||
role?: string | null | undefined;
|
||||
};
|
||||
|
||||
type AppContext = {
|
||||
|
|
|
|||
44
app/drizzle/0034_slippery_mongu.sql
Normal file
44
app/drizzle/0034_slippery_mongu.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
CREATE TABLE `invitation` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`role` text,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`inviter_id` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`inviter_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invitation_organizationId_idx` ON `invitation` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `invitation_email_idx` ON `invitation` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `member` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`role` text DEFAULT 'member' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `member_organizationId_idx` ON `member` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `member_userId_idx` ON `member` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `organization` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`logo` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`metadata` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_slug_uidx` ON `organization` (`slug`);--> statement-breakpoint
|
||||
ALTER TABLE `sessions_table` ADD `impersonated_by` text;--> statement-breakpoint
|
||||
ALTER TABLE `sessions_table` ADD `active_organization_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `users_table` ADD `role` text;--> statement-breakpoint
|
||||
ALTER TABLE `users_table` ADD `banned` integer DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE `users_table` ADD `ban_reason` text;--> statement-breakpoint
|
||||
ALTER TABLE `users_table` ADD `ban_expires` integer;
|
||||
1
app/drizzle/0035_default-admin-role.sql
Normal file
1
app/drizzle/0035_default-admin-role.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
UPDATE users_table SET role = 'admin' WHERE role IS NULL OR role = '';
|
||||
9
app/drizzle/0036_create-default-org.sql
Normal file
9
app/drizzle/0036_create-default-org.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
INSERT INTO organization (id, name, slug, created_at)
|
||||
SELECT
|
||||
'default-org-' || u.id as id,
|
||||
u.name || '''s Workspace' as name,
|
||||
lower(replace(u.name, ' ', '-')) || '-' || lower(hex(randomblob(2))) as slug,
|
||||
strftime('%s', 'now') * 1000 as created_at
|
||||
FROM users_table u
|
||||
LEFT JOIN member m ON u.id = m.user_id
|
||||
WHERE m.user_id IS NULL;
|
||||
10
app/drizzle/0037_create-default-member.sql
Normal file
10
app/drizzle/0037_create-default-member.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
INSERT INTO member (id, organization_id, user_id, role, created_at)
|
||||
SELECT
|
||||
'default-mem-' || u.id as id,
|
||||
'default-org-' || u.id as organization_id,
|
||||
u.id as user_id,
|
||||
'owner' as role,
|
||||
strftime('%s', 'now') * 1000 as created_at
|
||||
FROM users_table u
|
||||
LEFT JOIN member m ON u.id = m.user_id
|
||||
WHERE m.user_id IS NULL;
|
||||
5
app/drizzle/0038_shallow_pride.sql
Normal file
5
app/drizzle/0038_shallow_pride.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
DROP INDEX `volumes_table_name_unique`;--> statement-breakpoint
|
||||
ALTER TABLE `volumes_table` ADD `organization_id` text REFERENCES organization(id);--> statement-breakpoint
|
||||
ALTER TABLE `backup_schedules_table` ADD `organization_id` text REFERENCES organization(id);--> statement-breakpoint
|
||||
ALTER TABLE `notification_destinations_table` ADD `organization_id` text REFERENCES organization(id);--> statement-breakpoint
|
||||
ALTER TABLE `repositories_table` ADD `organization_id` text REFERENCES organization(id);
|
||||
15
app/drizzle/0039_backfill-entities-org-id.sql
Normal file
15
app/drizzle/0039_backfill-entities-org-id.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
UPDATE volumes_table
|
||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||
WHERE organization_id IS NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE backup_schedules_table
|
||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||
WHERE organization_id IS NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE notification_destinations_table
|
||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||
WHERE organization_id IS NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE repositories_table
|
||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||
WHERE organization_id IS NULL;
|
||||
95
app/drizzle/0040_tidy_maelstrom.sql
Normal file
95
app/drizzle/0040_tidy_maelstrom.sql
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_backup_schedules_table` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`short_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`volume_id` integer NOT NULL,
|
||||
`repository_id` text NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`cron_expression` text NOT NULL,
|
||||
`retention_policy` text,
|
||||
`exclude_patterns` text DEFAULT '[]',
|
||||
`exclude_if_present` text DEFAULT '[]',
|
||||
`include_patterns` text DEFAULT '[]',
|
||||
`last_backup_at` integer,
|
||||
`last_backup_status` text,
|
||||
`last_backup_error` text,
|
||||
`next_backup_at` integer,
|
||||
`one_file_system` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_backup_schedules_table`("id", "short_id", "name", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "exclude_if_present", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "one_file_system", "sort_order", "created_at", "updated_at", "organization_id") SELECT "id", "short_id", "name", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "exclude_if_present", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "one_file_system", "sort_order", "created_at", "updated_at", "organization_id" FROM `backup_schedules_table`;--> statement-breakpoint
|
||||
DROP TABLE `backup_schedules_table`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_backup_schedules_table` RENAME TO `backup_schedules_table`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `backup_schedules_table_short_id_unique` ON `backup_schedules_table` (`short_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `backup_schedules_table_name_unique` ON `backup_schedules_table` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_notification_destinations_table` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_notification_destinations_table`("id", "name", "enabled", "type", "config", "created_at", "updated_at", "organization_id") SELECT "id", "name", "enabled", "type", "config", "created_at", "updated_at", "organization_id" FROM `notification_destinations_table`;--> statement-breakpoint
|
||||
DROP TABLE `notification_destinations_table`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_notification_destinations_table` RENAME TO `notification_destinations_table`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `notification_destinations_table_name_unique` ON `notification_destinations_table` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_repositories_table` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`short_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`compression_mode` text DEFAULT 'auto',
|
||||
`status` text DEFAULT 'unknown',
|
||||
`last_checked` integer,
|
||||
`last_error` text,
|
||||
`upload_limit_enabled` integer DEFAULT false NOT NULL,
|
||||
`upload_limit_value` real DEFAULT 1 NOT NULL,
|
||||
`upload_limit_unit` text DEFAULT 'Mbps' NOT NULL,
|
||||
`download_limit_enabled` integer DEFAULT false NOT NULL,
|
||||
`download_limit_value` real DEFAULT 1 NOT NULL,
|
||||
`download_limit_unit` text DEFAULT 'Mbps' NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "upload_limit_enabled", "upload_limit_value", "upload_limit_unit", "download_limit_enabled", "download_limit_value", "download_limit_unit", "created_at", "updated_at", "organization_id") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "upload_limit_enabled", "upload_limit_value", "upload_limit_unit", "download_limit_enabled", "download_limit_value", "download_limit_unit", "created_at", "updated_at", "organization_id" FROM `repositories_table`;--> statement-breakpoint
|
||||
DROP TABLE `repositories_table`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_volumes_table` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`short_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`status` text DEFAULT 'unmounted' NOT NULL,
|
||||
`last_error` text,
|
||||
`last_health_check` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`auto_remount` integer DEFAULT true NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount", "organization_id") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount", "organization_id" FROM `volumes_table`;--> statement-breakpoint
|
||||
DROP TABLE `volumes_table`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);
|
||||
29
app/drizzle/0041_motionless_storm.sql
Normal file
29
app/drizzle/0041_motionless_storm.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
DROP INDEX `backup_schedules_table_name_unique`;--> statement-breakpoint
|
||||
DROP INDEX `notification_destinations_table_name_unique`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_users_table` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text,
|
||||
`has_downloaded_restic_password` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`email_verified` integer DEFAULT false NOT NULL,
|
||||
`image` text,
|
||||
`display_username` text,
|
||||
`two_factor_enabled` integer DEFAULT false NOT NULL,
|
||||
`role` text DEFAULT 'user' NOT NULL,
|
||||
`banned` integer DEFAULT false NOT NULL,
|
||||
`ban_reason` text,
|
||||
`ban_expires` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_users_table`("id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at", "name", "email", "email_verified", "image", "display_username", "two_factor_enabled", "role", "banned", "ban_reason", "ban_expires") SELECT "id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at", "name", "email", "email_verified", "image", "display_username", "two_factor_enabled", "role", "banned", "ban_reason", "ban_expires" FROM `users_table`;--> statement-breakpoint
|
||||
DROP TABLE `users_table`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_table_email_unique` ON `users_table` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `member_org_user_uidx` ON `member` (`organization_id`,`user_id`);
|
||||
1
app/drizzle/0042_watery_liz_osborn.sql
Normal file
1
app/drizzle/0042_watery_liz_osborn.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE UNIQUE INDEX `volumes_table_name_organization_id_unique` ON `volumes_table` (`name`,`organization_id`);
|
||||
1550
app/drizzle/meta/0034_snapshot.json
Normal file
1550
app/drizzle/meta/0034_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1550
app/drizzle/meta/0035_snapshot.json
Normal file
1550
app/drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1550
app/drizzle/meta/0036_snapshot.json
Normal file
1550
app/drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1550
app/drizzle/meta/0037_snapshot.json
Normal file
1550
app/drizzle/meta/0037_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1626
app/drizzle/meta/0038_snapshot.json
Normal file
1626
app/drizzle/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1626
app/drizzle/meta/0039_snapshot.json
Normal file
1626
app/drizzle/meta/0039_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1626
app/drizzle/meta/0040_snapshot.json
Normal file
1626
app/drizzle/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1620
app/drizzle/meta/0041_snapshot.json
Normal file
1620
app/drizzle/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1628
app/drizzle/meta/0042_snapshot.json
Normal file
1628
app/drizzle/meta/0042_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -239,6 +239,69 @@
|
|||
"when": 1768497767122,
|
||||
"tag": "0033_chunky_tyrannus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "6",
|
||||
"when": 1768653352902,
|
||||
"tag": "0034_slippery_mongu",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "6",
|
||||
"when": 1768653442143,
|
||||
"tag": "0035_default-admin-role",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "6",
|
||||
"when": 1768653580581,
|
||||
"tag": "0036_create-default-org",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "6",
|
||||
"when": 1768654184173,
|
||||
"tag": "0037_create-default-member",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "6",
|
||||
"when": 1768658258128,
|
||||
"tag": "0038_shallow_pride",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 39,
|
||||
"version": "6",
|
||||
"when": 1768658359845,
|
||||
"tag": "0039_backfill-entities-org-id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "6",
|
||||
"when": 1768659604809,
|
||||
"tag": "0040_tidy_maelstrom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1768684588784,
|
||||
"tag": "0041_motionless_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "6",
|
||||
"when": 1768687163221,
|
||||
"tag": "0042_watery_liz_osborn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { and, eq, ne } from "drizzle-orm";
|
|||
import { db } from "~/server/db/db";
|
||||
import { account, usersTable } from "~/server/db/schema";
|
||||
import type { AuthMiddlewareContext } from "../auth";
|
||||
import { UnauthorizedError } from "http-errors-enhanced";
|
||||
|
||||
export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) => {
|
||||
const { path, body } = ctx;
|
||||
|
|
@ -32,6 +33,7 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext)
|
|||
name: legacyUser.name,
|
||||
hasDownloadedResticPassword: legacyUser.hasDownloadedResticPassword,
|
||||
emailVerified: false,
|
||||
role: "admin", // In legacy system, the only user is an admin
|
||||
});
|
||||
|
||||
await tx.insert(account).values({
|
||||
|
|
@ -44,7 +46,7 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext)
|
|||
});
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid credentials");
|
||||
throw new UnauthorizedError("Invalid credentials");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import type { AuthMiddlewareContext } from "../auth";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { appMetadataTable } from "~/server/db/schema";
|
||||
import { ForbiddenError } from "http-errors-enhanced";
|
||||
import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants";
|
||||
|
||||
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
|
||||
const { path } = ctx;
|
||||
const existingUser = await db.query.usersTable.findFirst();
|
||||
|
||||
if (path !== "/sign-up/email") {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = await db.query.usersTable.findFirst();
|
||||
if (existingUser) {
|
||||
logger.error("Attempt to create a second administrator account blocked.");
|
||||
throw new Error("An administrator account already exists");
|
||||
const result = await db.query.appMetadataTable.findFirst({
|
||||
where: eq(appMetadataTable.key, REGISTRATION_ENABLED_KEY),
|
||||
});
|
||||
|
||||
if (result?.value !== "true" && existingUser) {
|
||||
logger.info("User registration attempt blocked: registrations are not enabled.");
|
||||
throw new ForbiddenError("User registrations are currently disabled. Please contact an administrator for access.");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import {
|
|||
type MiddlewareOptions,
|
||||
} from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { createAuthMiddleware, twoFactor, username } from "better-auth/plugins";
|
||||
import { admin, createAuthMiddleware, twoFactor, username, organization } from "better-auth/plugins";
|
||||
import { UnauthorizedError } from "http-errors-enhanced";
|
||||
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
|
||||
import { cryptoUtils } from "~/server/utils/crypto";
|
||||
import { db } from "~/server/db/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { config } from "../server/core/config";
|
||||
import { db } from "../server/db/db";
|
||||
import { cryptoUtils } from "../server/utils/crypto";
|
||||
import { organization as organizationTable, member, usersTable } from "../server/db/schema";
|
||||
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
|
||||
import { config } from "~/server/core/config";
|
||||
|
||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||
|
||||
|
|
@ -19,6 +22,9 @@ const createBetterAuth = (secret: string) =>
|
|||
betterAuth({
|
||||
secret,
|
||||
trustedOrigins: config.trustedOrigins ?? ["*"],
|
||||
onAPIError: {
|
||||
throw: true,
|
||||
},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
await ensureOnlyOneUser(ctx);
|
||||
|
|
@ -28,6 +34,76 @@ const createBetterAuth = (secret: string) =>
|
|||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
const anyUser = await db.query.usersTable.findFirst();
|
||||
const isFirstUser = !anyUser;
|
||||
|
||||
if (isFirstUser) {
|
||||
user.role = "admin";
|
||||
}
|
||||
|
||||
return { data: user };
|
||||
},
|
||||
after: async (user) => {
|
||||
const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4);
|
||||
|
||||
const resticPassword = cryptoUtils.generateResticPassword();
|
||||
const metadata = {
|
||||
resticPassword: await cryptoUtils.sealSecret(resticPassword),
|
||||
};
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const orgId = Bun.randomUUIDv7();
|
||||
|
||||
await tx.insert(organizationTable).values({
|
||||
name: `${user.name}'s Workspace`,
|
||||
slug: slug,
|
||||
id: orgId,
|
||||
createdAt: new Date(),
|
||||
metadata,
|
||||
});
|
||||
|
||||
await tx.insert(member).values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
userId: user.id,
|
||||
role: "owner",
|
||||
organizationId: orgId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
await db.delete(usersTable).where(eq(usersTable.id, user.id));
|
||||
|
||||
throw new Error(`Failed to create organization for user ${user.id}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
const orgMembership = await db.query.member.findFirst({
|
||||
where: eq(member.userId, session.userId),
|
||||
});
|
||||
|
||||
if (!orgMembership) {
|
||||
throw new UnauthorizedError("User does not belong to any organization");
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: orgMembership?.organizationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
|
|
@ -50,6 +126,12 @@ const createBetterAuth = (secret: string) =>
|
|||
},
|
||||
plugins: [
|
||||
username(),
|
||||
admin({
|
||||
defaultRole: "user",
|
||||
}),
|
||||
organization({
|
||||
allowUserToCreateOrganization: false,
|
||||
}),
|
||||
twoFactor({
|
||||
backupCodeOptions: {
|
||||
storeBackupCodes: "encrypted",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default [
|
|||
route("/", "./client/routes/root.tsx"),
|
||||
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
|
||||
route("volumes/create", "./client/modules/volumes/routes/create-volume.tsx"),
|
||||
route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"),
|
||||
route("volumes/:id", "./client/modules/volumes/routes/volume-details.tsx"),
|
||||
route("backups", "./client/modules/backups/routes/backups.tsx"),
|
||||
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
||||
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
|
||||
|
|
|
|||
207
app/server/__tests__/isolation.test.ts
Normal file
207
app/server/__tests__/isolation.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { createApp } from "~/server/app";
|
||||
import { createTestSession } from "~/test/helpers/auth";
|
||||
import { db } from "~/server/db/db";
|
||||
import { repositoriesTable, volumesTable, backupSchedulesTable } from "~/server/db/schema";
|
||||
import crypto from "node:crypto";
|
||||
import { generateShortId } from "~/server/utils/id";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
describe("multi-organization isolation", () => {
|
||||
test("should not be able to access repositories from another organization", async () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
expect(session1.organizationId).not.toBe(session2.organizationId);
|
||||
|
||||
const repoId = crypto.randomUUID();
|
||||
const shortId = generateShortId();
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: repoId,
|
||||
shortId,
|
||||
name: "Org 1 Repo",
|
||||
type: "local",
|
||||
config: { backend: "local", name: "org1repo", path: "/tmp/repo1" },
|
||||
organizationId: session1.organizationId,
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/v1/repositories/${repoId}`, {
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session2.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Repository not found");
|
||||
|
||||
const resOk = await app.request(`/api/v1/repositories/${repoId}`, {
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session1.token}`,
|
||||
},
|
||||
});
|
||||
expect(resOk.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should not list repositories from another organization", async () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: crypto.randomUUID(),
|
||||
shortId: generateShortId(),
|
||||
name: "Org 1 Repo",
|
||||
type: "local",
|
||||
config: { backend: "local", name: "org1repo-list", path: "/tmp/repo1" },
|
||||
organizationId: session1.organizationId,
|
||||
});
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: crypto.randomUUID(),
|
||||
shortId: generateShortId(),
|
||||
name: "Org 2 Repo",
|
||||
type: "local",
|
||||
config: { backend: "local", name: "org2repo-list", path: "/tmp/repo2" },
|
||||
organizationId: session2.organizationId,
|
||||
});
|
||||
|
||||
const res1 = await app.request("/api/v1/repositories", {
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session1.token}`,
|
||||
},
|
||||
});
|
||||
const list1 = await res1.json();
|
||||
|
||||
expect(list1.length).toBeGreaterThanOrEqual(1);
|
||||
expect(list1.some((r: any) => r.name === "Org 2 Repo")).toBe(false);
|
||||
|
||||
const res2 = await app.request("/api/v1/repositories", {
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session2.token}`,
|
||||
},
|
||||
});
|
||||
const list2 = await res2.json();
|
||||
expect(list2.some((r: any) => r.name === "Org 1 Repo")).toBe(false);
|
||||
expect(list2.some((r: any) => r.name === "Org 2 Repo")).toBe(true);
|
||||
});
|
||||
|
||||
test("should not be able to access volumes from another organization", async () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
const volumeId = Math.floor(Math.random() * 1000000);
|
||||
await db.insert(volumesTable).values({
|
||||
id: volumeId,
|
||||
shortId: generateShortId(),
|
||||
name: "Org 1 Volume",
|
||||
type: "directory",
|
||||
config: { backend: "directory", path: "/tmp/vol1" },
|
||||
organizationId: session1.organizationId,
|
||||
status: "unmounted",
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/v1/volumes/${volumeId}`, {
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session2.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should not be able to create a backup schedule referencing resources from another organization", async () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
const vol1Id = Math.floor(Math.random() * 1000000);
|
||||
await db.insert(volumesTable).values({
|
||||
id: vol1Id,
|
||||
shortId: generateShortId(),
|
||||
name: "Org 1 Volume",
|
||||
type: "directory",
|
||||
config: { backend: "directory", path: "/tmp/vol1" },
|
||||
organizationId: session1.organizationId,
|
||||
status: "unmounted",
|
||||
});
|
||||
|
||||
const repo1Id = crypto.randomUUID();
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: repo1Id,
|
||||
shortId: generateShortId(),
|
||||
name: "Org 1 Repo",
|
||||
type: "local",
|
||||
config: { backend: "local", name: "org1repo", path: "/tmp/repo1" },
|
||||
organizationId: session1.organizationId,
|
||||
});
|
||||
|
||||
const res = await app.request("/api/v1/backups", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session2.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Malicious Schedule",
|
||||
volumeId: vol1Id,
|
||||
repositoryId: repo1Id,
|
||||
enabled: true,
|
||||
cronExpression: "0 0 * * *",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should not be able to access backup schedules from another organization", async () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
const vol1Id = Math.floor(Math.random() * 1000000);
|
||||
await db.insert(volumesTable).values({
|
||||
id: vol1Id,
|
||||
shortId: generateShortId(),
|
||||
name: "Org 1 Volume",
|
||||
type: "directory",
|
||||
config: { backend: "directory", path: "/tmp/vol1" },
|
||||
organizationId: session1.organizationId,
|
||||
status: "unmounted",
|
||||
});
|
||||
const repo1Id = crypto.randomUUID();
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: repo1Id,
|
||||
shortId: generateShortId(),
|
||||
name: "Org 1 Repo",
|
||||
type: "local",
|
||||
config: { backend: "local", name: "org1repo", path: "/tmp/repo1" },
|
||||
organizationId: session1.organizationId,
|
||||
});
|
||||
|
||||
const [schedule] = await db
|
||||
.insert(backupSchedulesTable)
|
||||
.values({
|
||||
shortId: generateShortId(),
|
||||
name: "Org 1 Schedule",
|
||||
volumeId: vol1Id,
|
||||
repositoryId: repo1Id,
|
||||
cronExpression: "0 0 * * *",
|
||||
organizationId: session1.organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const res = await app.request(`/api/v1/backups/${schedule.id}`, {
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session2.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const resOk = await app.request(`/api/v1/backups/${schedule.id}`, {
|
||||
headers: {
|
||||
Cookie: `better-auth.session_token=${session1.token}`,
|
||||
},
|
||||
});
|
||||
expect(resOk.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,7 @@ const envSchema = type({
|
|||
APP_VERSION: "string = 'dev'",
|
||||
TRUSTED_ORIGINS: "string?",
|
||||
DISABLE_RATE_LIMITING: 'string = "false"',
|
||||
APP_SECRET: "32 <= string <= 256",
|
||||
}).pipe((s) => ({
|
||||
__prod__: s.NODE_ENV === "production",
|
||||
environment: s.NODE_ENV,
|
||||
|
|
@ -45,13 +46,35 @@ const envSchema = type({
|
|||
appVersion: s.APP_VERSION,
|
||||
trustedOrigins: s.TRUSTED_ORIGINS?.split(",").map((origin) => origin.trim()),
|
||||
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true",
|
||||
appSecret: s.APP_SECRET,
|
||||
}));
|
||||
|
||||
const parseConfig = (env: unknown) => {
|
||||
const result = envSchema(env);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
console.error(`Environment variable validation failed: ${result.toString()}`);
|
||||
if (!process.env.APP_SECRET) {
|
||||
const errorMessage = [
|
||||
"",
|
||||
"================================================================================",
|
||||
"APP_SECRET is not configured.",
|
||||
"",
|
||||
"This secret is required for encrypting sensitive data in the database.",
|
||||
"",
|
||||
"To generate a new secret, run:",
|
||||
" openssl rand -hex 32",
|
||||
"",
|
||||
"Then set the APP_SECRET environment variable with the generated value.",
|
||||
"",
|
||||
"IMPORTANT: Store this secret securely and back it up. If lost, encrypted data",
|
||||
"in the database will be unrecoverable.",
|
||||
"================================================================================",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
console.error(errorMessage);
|
||||
}
|
||||
console.error(`Environment variable validation failed: ${result.summary}`);
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
|
||||
|
|
|
|||
24
app/server/core/request-context.ts
Normal file
24
app/server/core/request-context.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export type RequestContext = {
|
||||
organizationId: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
const requestContextStorage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export const withContext = <T>(context: RequestContext, fn: () => T): T => {
|
||||
return requestContextStorage.run(context, fn);
|
||||
};
|
||||
|
||||
export const getRequestContext = (): RequestContext => {
|
||||
const context = requestContextStorage.getStore();
|
||||
|
||||
if (!context?.organizationId) {
|
||||
throw new Error("Organization context is missing");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const getOrganizationId = () => getRequestContext().organizationId;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { relations, sql } from "drizzle-orm";
|
||||
import { index, int, integer, sqliteTable, text, real, primaryKey, unique } from "drizzle-orm/sqlite-core";
|
||||
import { index, int, integer, sqliteTable, text, real, primaryKey, unique, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
CompressionMode,
|
||||
RepositoryBackend,
|
||||
|
|
@ -10,32 +10,6 @@ import type {
|
|||
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
|
||||
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
|
||||
|
||||
/**
|
||||
* Volumes Table
|
||||
*/
|
||||
export const volumesTable = sqliteTable("volumes_table", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
shortId: text("short_id").notNull().unique(),
|
||||
name: text().notNull().unique(),
|
||||
type: text().$type<BackendType>().notNull(),
|
||||
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
||||
lastError: text("last_error"),
|
||||
lastHealthCheck: integer("last_health_check", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
createdAt: integer("created_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: integer("updated_at", { mode: "number" })
|
||||
.notNull()
|
||||
.$onUpdate(() => Date.now())
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
||||
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
||||
});
|
||||
export type Volume = typeof volumesTable.$inferSelect;
|
||||
export type VolumeInsert = typeof volumesTable.$inferInsert;
|
||||
|
||||
/**
|
||||
* Users Table
|
||||
*/
|
||||
|
|
@ -57,6 +31,10 @@ export const usersTable = sqliteTable("users_table", {
|
|||
image: text("image"),
|
||||
displayUsername: text("display_username"),
|
||||
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
role: text("role").notNull().default("user"),
|
||||
banned: integer("banned", { mode: "boolean" }).notNull().default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
|
||||
});
|
||||
|
||||
export type User = typeof usersTable.$inferSelect;
|
||||
|
|
@ -78,6 +56,8 @@ export const sessionsTable = sqliteTable(
|
|||
.default(sql`(unixepoch() * 1000)`),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
},
|
||||
(table) => [index("sessionsTable_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
|
@ -132,25 +112,93 @@ export const verification = sqliteTable(
|
|||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const userRelations = relations(usersTable, ({ many }) => ({
|
||||
sessions: many(sessionsTable),
|
||||
accounts: many(account),
|
||||
twoFactors: many(twoFactor),
|
||||
}));
|
||||
export type OrganizationMetadata = {
|
||||
resticPassword: string;
|
||||
};
|
||||
|
||||
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
|
||||
user: one(usersTable, {
|
||||
fields: [sessionsTable.userId],
|
||||
references: [usersTable.id],
|
||||
}),
|
||||
}));
|
||||
export const organization = sqliteTable(
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
metadata: text("metadata", { mode: "json" }).$type<OrganizationMetadata>(),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
);
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(usersTable, {
|
||||
fields: [account.userId],
|
||||
references: [usersTable.id],
|
||||
}),
|
||||
}));
|
||||
export const member = sqliteTable(
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
uniqueIndex("member_org_user_uidx").on(table.organizationId, table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = sqliteTable(
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Volumes Table
|
||||
*/
|
||||
export const volumesTable = sqliteTable("volumes_table", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
shortId: text("short_id").notNull().unique(),
|
||||
name: text().notNull(),
|
||||
type: text().$type<BackendType>().notNull(),
|
||||
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
||||
lastError: text("last_error"),
|
||||
lastHealthCheck: integer("last_health_check", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
createdAt: integer("created_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: integer("updated_at", { mode: "number" })
|
||||
.notNull()
|
||||
.$onUpdate(() => Date.now())
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
||||
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
}, (table) => [unique().on(table.name, table.organizationId)]);
|
||||
export type Volume = typeof volumesTable.$inferSelect;
|
||||
export type VolumeInsert = typeof volumesTable.$inferInsert;
|
||||
|
||||
/**
|
||||
* Repositories Table
|
||||
|
|
@ -177,6 +225,9 @@ export const repositoriesTable = sqliteTable("repositories_table", {
|
|||
updatedAt: int("updated_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
});
|
||||
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
||||
|
|
@ -187,7 +238,7 @@ export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
|||
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
shortId: text("short_id").notNull().unique(),
|
||||
name: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
volumeId: int("volume_id")
|
||||
.notNull()
|
||||
.references(() => volumesTable.id, { onDelete: "cascade" }),
|
||||
|
|
@ -220,21 +271,12 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||
updatedAt: int("updated_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
});
|
||||
export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert;
|
||||
|
||||
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
|
||||
volume: one(volumesTable, {
|
||||
fields: [backupSchedulesTable.volumeId],
|
||||
references: [volumesTable.id],
|
||||
}),
|
||||
repository: one(repositoriesTable, {
|
||||
fields: [backupSchedulesTable.repositoryId],
|
||||
references: [repositoriesTable.id],
|
||||
}),
|
||||
notifications: many(backupScheduleNotificationsTable),
|
||||
mirrors: many(backupScheduleMirrorsTable),
|
||||
}));
|
||||
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||
|
||||
/**
|
||||
|
|
@ -242,7 +284,7 @@ export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
|||
*/
|
||||
export const notificationDestinationsTable = sqliteTable("notification_destinations_table", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
type: text().$type<NotificationType>().notNull(),
|
||||
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
|
||||
|
|
@ -252,10 +294,10 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
|
|||
updatedAt: int("updated_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
});
|
||||
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
|
||||
schedules: many(backupScheduleNotificationsTable),
|
||||
}));
|
||||
export type NotificationDestination = typeof notificationDestinationsTable.$inferSelect;
|
||||
|
||||
/**
|
||||
|
|
@ -280,16 +322,6 @@ export const backupScheduleNotificationsTable = sqliteTable(
|
|||
},
|
||||
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
|
||||
);
|
||||
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
|
||||
schedule: one(backupSchedulesTable, {
|
||||
fields: [backupScheduleNotificationsTable.scheduleId],
|
||||
references: [backupSchedulesTable.id],
|
||||
}),
|
||||
destination: one(notificationDestinationsTable, {
|
||||
fields: [backupScheduleNotificationsTable.destinationId],
|
||||
references: [notificationDestinationsTable.id],
|
||||
}),
|
||||
}));
|
||||
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
|
||||
|
||||
/**
|
||||
|
|
@ -317,16 +349,6 @@ export const backupScheduleMirrorsTable = sqliteTable(
|
|||
(table) => [unique().on(table.scheduleId, table.repositoryId)],
|
||||
);
|
||||
|
||||
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
|
||||
schedule: one(backupSchedulesTable, {
|
||||
fields: [backupScheduleMirrorsTable.scheduleId],
|
||||
references: [backupSchedulesTable.id],
|
||||
}),
|
||||
repository: one(repositoriesTable, {
|
||||
fields: [backupScheduleMirrorsTable.repositoryId],
|
||||
references: [repositoriesTable.id],
|
||||
}),
|
||||
}));
|
||||
export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect;
|
||||
|
||||
/**
|
||||
|
|
@ -357,3 +379,102 @@ export const twoFactor = sqliteTable(
|
|||
},
|
||||
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const userRelations = relations(usersTable, ({ many }) => ({
|
||||
sessions: many(sessionsTable),
|
||||
accounts: many(account),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
twoFactors: many(twoFactor),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
|
||||
user: one(usersTable, {
|
||||
fields: [sessionsTable.userId],
|
||||
references: [usersTable.id],
|
||||
}),
|
||||
activeOrganization: one(organization, {
|
||||
fields: [sessionsTable.activeOrganizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(usersTable, {
|
||||
fields: [account.userId],
|
||||
references: [usersTable.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const memberRelations = relations(member, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
usersTable: one(usersTable, {
|
||||
fields: [member.userId],
|
||||
references: [usersTable.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
usersTable: one(usersTable, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [usersTable.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
usersTable: one(usersTable, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [usersTable.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
|
||||
schedule: one(backupSchedulesTable, {
|
||||
fields: [backupScheduleMirrorsTable.scheduleId],
|
||||
references: [backupSchedulesTable.id],
|
||||
}),
|
||||
repository: one(repositoriesTable, {
|
||||
fields: [backupScheduleMirrorsTable.repositoryId],
|
||||
references: [repositoriesTable.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
|
||||
volume: one(volumesTable, {
|
||||
fields: [backupSchedulesTable.volumeId],
|
||||
references: [volumesTable.id],
|
||||
}),
|
||||
repository: one(repositoriesTable, {
|
||||
fields: [backupSchedulesTable.repositoryId],
|
||||
references: [repositoriesTable.id],
|
||||
}),
|
||||
notifications: many(backupScheduleNotificationsTable),
|
||||
mirrors: many(backupScheduleMirrorsTable),
|
||||
}));
|
||||
|
||||
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
|
||||
schedules: many(backupScheduleNotificationsTable),
|
||||
}));
|
||||
|
||||
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
|
||||
schedule: one(backupSchedulesTable, {
|
||||
fields: [backupScheduleNotificationsTable.scheduleId],
|
||||
references: [backupSchedulesTable.id],
|
||||
}),
|
||||
destination: one(notificationDestinationsTable, {
|
||||
fields: [backupScheduleNotificationsTable.destinationId],
|
||||
references: [notificationDestinationsTable.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { logger } from "../utils/logger";
|
|||
import { db } from "../db/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { volumesTable } from "../db/schema";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
export class VolumeAutoRemountJob extends Job {
|
||||
async run() {
|
||||
|
|
@ -16,7 +17,9 @@ export class VolumeAutoRemountJob extends Job {
|
|||
for (const volume of volumes) {
|
||||
if (volume.autoRemount) {
|
||||
try {
|
||||
await volumeService.mountVolume(volume.name);
|
||||
await withContext({ organizationId: volume.organizationId }, async () => {
|
||||
await volumeService.mountVolume(volume.id);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to auto-remount volume ${volume.name}:`, err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,41 @@
|
|||
import { Job } from "../core/scheduler";
|
||||
import { backupsService } from "../modules/backups/backups.service";
|
||||
import { logger } from "../utils/logger";
|
||||
import { db } from "../db/db";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
export class BackupExecutionJob extends Job {
|
||||
async run() {
|
||||
logger.debug("Checking for backup schedules to execute...");
|
||||
|
||||
const scheduleIds = await backupsService.getSchedulesToExecute();
|
||||
const organizations = await db.query.organization.findMany({});
|
||||
|
||||
if (scheduleIds.length === 0) {
|
||||
logger.debug("No backup schedules to execute");
|
||||
return { done: true, timestamp: new Date(), executed: 0 };
|
||||
}
|
||||
let totalExecuted = 0;
|
||||
|
||||
logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`);
|
||||
for (const org of organizations) {
|
||||
await withContext({ organizationId: org.id }, async () => {
|
||||
const scheduleIds = await backupsService.getSchedulesToExecute();
|
||||
|
||||
for (const scheduleId of scheduleIds) {
|
||||
backupsService.executeBackup(scheduleId).catch((err) => {
|
||||
logger.error(`Error executing backup for schedule ${scheduleId}:`, err);
|
||||
if (scheduleIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute for organization ${org.name}`);
|
||||
|
||||
for (const scheduleId of scheduleIds) {
|
||||
backupsService.executeBackup(scheduleId).catch((err) => {
|
||||
logger.error(`Error executing backup for schedule ${scheduleId}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
totalExecuted += scheduleIds.length;
|
||||
});
|
||||
}
|
||||
|
||||
return { done: true, timestamp: new Date(), executed: scheduleIds.length };
|
||||
if (totalExecuted === 0) {
|
||||
logger.debug("No backup schedules to execute");
|
||||
}
|
||||
|
||||
return { done: true, timestamp: new Date(), executed: totalExecuted };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,23 @@ import { logger } from "../utils/logger";
|
|||
import { executeUnmount } from "../modules/backends/utils/backend-utils";
|
||||
import { toMessage } from "../utils/errors";
|
||||
import { VOLUME_MOUNT_BASE } from "../core/constants";
|
||||
import { db } from "../db/db";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
export class CleanupDanglingMountsJob extends Job {
|
||||
async run() {
|
||||
const allVolumes = await volumeService.listVolumes();
|
||||
const organizations = await db.query.organization.findMany({});
|
||||
if (organizations.length === 0) {
|
||||
logger.warn("No organizations found; skipping dangling mount cleanup to avoid false positives.");
|
||||
return { done: true, timestamp: new Date() };
|
||||
}
|
||||
|
||||
const allVolumes = [];
|
||||
for (const org of organizations) {
|
||||
const volumes = await withContext({ organizationId: org.id }, async () => volumeService.listVolumes());
|
||||
allVolumes.push(...volumes);
|
||||
}
|
||||
|
||||
const allSystemMounts = await readMountInfo();
|
||||
|
||||
for (const mount of allSystemMounts) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { logger } from "../utils/logger";
|
|||
import { db } from "../db/db";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { volumesTable } from "../db/schema";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
export class VolumeHealthCheckJob extends Job {
|
||||
async run() {
|
||||
|
|
@ -14,10 +15,12 @@ export class VolumeHealthCheckJob extends Job {
|
|||
});
|
||||
|
||||
for (const volume of volumes) {
|
||||
const { status } = await volumeService.checkHealth(volume.name);
|
||||
if (status === "error" && volume.autoRemount) {
|
||||
await volumeService.mountVolume(volume.name);
|
||||
}
|
||||
await withContext({ organizationId: volume.organizationId }, async () => {
|
||||
const { status } = await volumeService.checkHealth(volume.id);
|
||||
if (status === "error" && volume.autoRemount) {
|
||||
await volumeService.mountVolume(volume.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { done: true, timestamp: new Date() };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { logger } from "../utils/logger";
|
|||
import { db } from "../db/db";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { repositoriesTable } from "../db/schema";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
export class RepositoryHealthCheckJob extends Job {
|
||||
async run() {
|
||||
|
|
@ -15,7 +16,9 @@ export class RepositoryHealthCheckJob extends Job {
|
|||
|
||||
for (const repository of repositories) {
|
||||
try {
|
||||
await repositoriesService.checkHealth(repository.id);
|
||||
await withContext({ organizationId: repository.organizationId }, async () => {
|
||||
await repositoriesService.checkHealth(repository.id);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Health check failed for repository ${repository.name}:`, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { createMiddleware } from "hono/factory";
|
||||
import { auth } from "~/lib/auth";
|
||||
import { db } from "~/server/db/db";
|
||||
import { member } from "~/server/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { withContext } from "~/server/core/request-context";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
|
|
@ -7,7 +11,9 @@ declare module "hono" {
|
|||
id: string;
|
||||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
role?: string | null | undefined;
|
||||
};
|
||||
organizationId: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16,17 +22,40 @@ declare module "hono" {
|
|||
* Verifies the session cookie and attaches user to context
|
||||
*/
|
||||
export const requireAuth = createMiddleware(async (c, next) => {
|
||||
const session = await auth.api.getSession({
|
||||
const sess = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
const { user } = session ?? {};
|
||||
const { user, session } = sess ?? {};
|
||||
const { activeOrganizationId } = session ?? {};
|
||||
|
||||
if (!user) {
|
||||
if (!user || !session || !activeOrganizationId) {
|
||||
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
|
||||
}
|
||||
|
||||
c.set("user", user);
|
||||
c.set("organizationId", activeOrganizationId);
|
||||
|
||||
await withContext({ organizationId: activeOrganizationId, userId: user.id }, async () => {
|
||||
await next();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware to require organization owner or admin role
|
||||
* Verifies the user has the required role in the current organization
|
||||
*/
|
||||
export const requireOrgAdmin = createMiddleware(async (c, next) => {
|
||||
const user = c.get("user");
|
||||
const organizationId = c.get("organizationId");
|
||||
|
||||
const membership = await db.query.member.findFirst({
|
||||
where: and(eq(member.userId, user.id), eq(member.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
|
||||
return c.json({ message: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { generateBackupOutput } from "~/test/helpers/restic";
|
|||
import { getVolumePath } from "../../volumes/helpers";
|
||||
import { restic } from "~/server/utils/restic";
|
||||
import path from "node:path";
|
||||
import { TEST_ORG_ID } from "~/test/helpers/organization";
|
||||
import * as context from "~/server/core/request-context";
|
||||
|
||||
const backupMock = mock(() => Promise.resolve({ exitCode: 0, result: JSON.parse(generateBackupOutput()) }));
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ beforeEach(() => {
|
|||
backupMock.mockClear();
|
||||
spyOn(restic, "backup").mockImplementation(backupMock);
|
||||
spyOn(restic, "forget").mockImplementation(mock(() => Promise.resolve({ success: true })));
|
||||
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import { createTestRepository } from "~/test/helpers/repository";
|
|||
import { generateBackupOutput } from "~/test/helpers/restic";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import * as spawnModule from "~/server/utils/spawn";
|
||||
import { TEST_ORG_ID } from "~/test/helpers/organization";
|
||||
import * as context from "~/server/core/request-context";
|
||||
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
|
||||
|
||||
beforeEach(() => {
|
||||
resticBackupMock.mockClear();
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
|
||||
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export const backupScheduleController = new Hono()
|
|||
})
|
||||
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
const schedule = await backupsService.getSchedule(Number(scheduleId));
|
||||
|
||||
return c.json<GetBackupScheduleDto>(schedule, 200);
|
||||
|
|
@ -65,7 +64,6 @@ export const backupScheduleController = new Hono()
|
|||
})
|
||||
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const schedule = await backupsService.createSchedule(body);
|
||||
|
||||
return c.json<CreateBackupScheduleDto>(schedule, 201);
|
||||
|
|
@ -73,21 +71,18 @@ export const backupScheduleController = new Hono()
|
|||
.patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const schedule = await backupsService.updateSchedule(Number(scheduleId), body);
|
||||
|
||||
return c.json<UpdateBackupScheduleDto>(schedule, 200);
|
||||
})
|
||||
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
await backupsService.deleteSchedule(Number(scheduleId));
|
||||
|
||||
return c.json<DeleteBackupScheduleDto>({ success: true }, 200);
|
||||
})
|
||||
.post("/:scheduleId/run", runBackupNowDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
backupsService.executeBackup(Number(scheduleId), true).catch((err) => {
|
||||
console.error(`Error executing manual backup for schedule ${scheduleId}:`, err);
|
||||
});
|
||||
|
|
@ -96,14 +91,12 @@ export const backupScheduleController = new Hono()
|
|||
})
|
||||
.post("/:scheduleId/stop", stopBackupDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
await backupsService.stopBackup(Number(scheduleId));
|
||||
|
||||
return c.json<StopBackupDto>({ success: true }, 200);
|
||||
})
|
||||
.post("/:scheduleId/forget", runForgetDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
await backupsService.runForget(Number(scheduleId));
|
||||
|
||||
return c.json<RunForgetDto>({ success: true }, 200);
|
||||
|
|
@ -147,7 +140,6 @@ export const backupScheduleController = new Hono()
|
|||
})
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { repoMutex } from "../../core/repository-mutex";
|
|||
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
|
||||
import path from "node:path";
|
||||
import { generateShortId } from "~/server/utils/id";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
|
||||
const runningBackups = new Map<number, AbortController>();
|
||||
|
||||
|
|
@ -57,7 +58,9 @@ const processPattern = (pattern: string, volumePath: string): string => {
|
|||
};
|
||||
|
||||
const listSchedules = async () => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedules = await db.query.backupSchedulesTable.findMany({
|
||||
where: eq(backupSchedulesTable.organizationId, organizationId),
|
||||
with: {
|
||||
volume: true,
|
||||
repository: true,
|
||||
|
|
@ -68,8 +71,9 @@ const listSchedules = async () => {
|
|||
};
|
||||
|
||||
const getSchedule = async (scheduleId: number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
with: {
|
||||
volume: true,
|
||||
repository: true,
|
||||
|
|
@ -84,12 +88,13 @@ const getSchedule = async (scheduleId: number) => {
|
|||
};
|
||||
|
||||
const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||
const organizationId = getOrganizationId();
|
||||
if (!cron.validate(data.cronExpression)) {
|
||||
throw new BadRequestError("Invalid cron expression");
|
||||
}
|
||||
|
||||
const existingName = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.name, data.name),
|
||||
where: and(eq(backupSchedulesTable.name, data.name), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (existingName) {
|
||||
|
|
@ -97,7 +102,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
}
|
||||
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.id, data.volumeId),
|
||||
where: and(eq(volumesTable.id, data.volumeId), eq(volumesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!volume) {
|
||||
|
|
@ -105,7 +110,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
}
|
||||
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, data.repositoryId),
|
||||
where: and(eq(repositoriesTable.id, data.repositoryId), eq(repositoriesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -129,6 +134,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
oneFileSystem: data.oneFileSystem,
|
||||
nextBackupAt: nextBackupAt,
|
||||
shortId: generateShortId(),
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -140,8 +146,9 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
};
|
||||
|
||||
const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
|
|
@ -154,7 +161,7 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
|
|||
|
||||
if (data.name) {
|
||||
const existingName = await db.query.backupSchedulesTable.findFirst({
|
||||
where: and(eq(backupSchedulesTable.name, data.name), ne(backupSchedulesTable.id, scheduleId)),
|
||||
where: and(eq(backupSchedulesTable.name, data.name), ne(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (existingName) {
|
||||
|
|
@ -163,7 +170,7 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
|
|||
}
|
||||
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, data.repositoryId),
|
||||
where: and(eq(repositoriesTable.id, data.repositoryId), eq(repositoriesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -176,7 +183,7 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
|
|||
const [updated] = await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({ ...data, nextBackupAt, updatedAt: Date.now() })
|
||||
.where(eq(backupSchedulesTable.id, scheduleId))
|
||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
|
|
@ -187,20 +194,24 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
|
|||
};
|
||||
|
||||
const deleteSchedule = async (scheduleId: number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
await db.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId));
|
||||
await db
|
||||
.delete(backupSchedulesTable)
|
||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
||||
};
|
||||
|
||||
const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
|
|
@ -218,7 +229,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||
}
|
||||
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.id, schedule.volumeId),
|
||||
where: and(eq(volumesTable.id, schedule.volumeId), eq(volumesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!volume) {
|
||||
|
|
@ -226,7 +237,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||
}
|
||||
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, schedule.repositoryId),
|
||||
where: and(eq(repositoriesTable.id, schedule.repositoryId), eq(repositoriesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -265,7 +276,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||
lastBackupError: null,
|
||||
nextBackupAt,
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
||||
|
||||
const abortController = new AbortController();
|
||||
runningBackups.set(scheduleId, abortController);
|
||||
|
|
@ -304,6 +315,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||
const result = await restic.backup(repository.config, volumePath, {
|
||||
...backupOptions,
|
||||
compressionMode: repository.compressionMode ?? "auto",
|
||||
organizationId,
|
||||
onProgress: (progress) => {
|
||||
serverEvents.emit("backup:progress", {
|
||||
scheduleId,
|
||||
|
|
@ -342,7 +354,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||
nextBackupAt: nextBackupAt,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
||||
|
||||
if (finalStatus === "warning") {
|
||||
logger.warn(
|
||||
|
|
@ -383,7 +395,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||
lastBackupError: toMessage(error),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
||||
|
||||
serverEvents.emit("backup:completed", {
|
||||
scheduleId,
|
||||
|
|
@ -408,11 +420,13 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||
};
|
||||
|
||||
const getSchedulesToExecute = async () => {
|
||||
const organizationId = getOrganizationId();
|
||||
const now = Date.now();
|
||||
const schedules = await db.query.backupSchedulesTable.findMany({
|
||||
where: and(
|
||||
eq(backupSchedulesTable.enabled, true),
|
||||
or(ne(backupSchedulesTable.lastBackupStatus, "in_progress"), isNull(backupSchedulesTable.lastBackupStatus)),
|
||||
eq(backupSchedulesTable.organizationId, organizationId),
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -428,8 +442,9 @@ const getSchedulesToExecute = async () => {
|
|||
};
|
||||
|
||||
const getScheduleForVolume = async (volumeId: number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.volumeId, volumeId),
|
||||
where: and(eq(backupSchedulesTable.volumeId, volumeId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
with: { volume: true, repository: true },
|
||||
});
|
||||
|
||||
|
|
@ -437,8 +452,9 @@ const getScheduleForVolume = async (volumeId: number) => {
|
|||
};
|
||||
|
||||
const stopBackup = async (scheduleId: number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
|
|
@ -462,13 +478,14 @@ const stopBackup = async (scheduleId: number) => {
|
|||
lastBackupError: "Backup was stopped by user",
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
||||
}
|
||||
};
|
||||
|
||||
const runForget = async (scheduleId: number, repositoryId?: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
|
|
@ -480,7 +497,7 @@ const runForget = async (scheduleId: number, repositoryId?: string) => {
|
|||
}
|
||||
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, repositoryId ?? schedule.repositoryId),
|
||||
where: and(eq(repositoriesTable.id, repositoryId ?? schedule.repositoryId), eq(repositoriesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -490,7 +507,7 @@ const runForget = async (scheduleId: number, repositoryId?: string) => {
|
|||
logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
|
||||
try {
|
||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId });
|
||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId });
|
||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||
} finally {
|
||||
releaseLock();
|
||||
|
|
@ -500,8 +517,9 @@ const runForget = async (scheduleId: number, repositoryId?: string) => {
|
|||
};
|
||||
|
||||
const getMirrors = async (scheduleId: number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
|
|
@ -517,8 +535,9 @@ const getMirrors = async (scheduleId: number) => {
|
|||
};
|
||||
|
||||
const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
with: { repository: true },
|
||||
});
|
||||
|
||||
|
|
@ -532,7 +551,7 @@ const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody
|
|||
}
|
||||
|
||||
const repo = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, mirror.repositoryId),
|
||||
where: and(eq(repositoriesTable.id, mirror.repositoryId), eq(repositoriesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
|
|
@ -585,8 +604,9 @@ const copyToMirrors = async (
|
|||
sourceRepository: { id: string; config: (typeof repositoriesTable.$inferSelect)["config"] },
|
||||
retentionPolicy: (typeof backupSchedulesTable.$inferSelect)["retentionPolicy"],
|
||||
) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
|
|
@ -622,7 +642,7 @@ const copyToMirrors = async (
|
|||
const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`);
|
||||
|
||||
try {
|
||||
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId });
|
||||
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });
|
||||
cache.delByPrefix(`snapshots:${mirror.repository.id}:`);
|
||||
} finally {
|
||||
releaseSource();
|
||||
|
|
@ -671,8 +691,9 @@ const copyToMirrors = async (
|
|||
};
|
||||
|
||||
const getMirrorCompatibility = async (scheduleId: number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||
with: { repository: true },
|
||||
});
|
||||
|
||||
|
|
@ -680,7 +701,9 @@ const getMirrorCompatibility = async (scheduleId: number) => {
|
|||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
const allRepositories = await db.query.repositoriesTable.findMany();
|
||||
const allRepositories = await db.query.repositoriesTable.findMany({
|
||||
where: eq(repositoriesTable.organizationId, organizationId),
|
||||
});
|
||||
const repos = allRepositories.filter((repo) => repo.id !== schedule.repositoryId);
|
||||
|
||||
const compatibility = await Promise.all(
|
||||
|
|
@ -691,12 +714,14 @@ const getMirrorCompatibility = async (scheduleId: number) => {
|
|||
};
|
||||
|
||||
const reorderSchedules = async (scheduleIds: number[]) => {
|
||||
const organizationId = getOrganizationId();
|
||||
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({
|
||||
where: eq(backupSchedulesTable.organizationId, organizationId),
|
||||
columns: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existingSchedules.map((s) => s.id));
|
||||
|
|
@ -714,7 +739,7 @@ const reorderSchedules = async (scheduleIds: number[]) => {
|
|||
tx
|
||||
.update(backupSchedulesTable)
|
||||
.set({ sortOrder: index, updatedAt: now })
|
||||
.where(eq(backupSchedulesTable.id, scheduleId)),
|
||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { v00001 } from "./migrations/00001-retag-snapshots";
|
||||
import { usersTable } from "~/server/db/schema";
|
||||
import { v00002 } from "./migrations/00002-isolate-restic-passwords";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { appMetadataTable } from "../../db/schema";
|
||||
import { appMetadataTable, usersTable } from "../../db/schema";
|
||||
import { db } from "../../db/db";
|
||||
|
||||
const MIGRATION_KEY_PREFIX = "migration:";
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ type MigrationEntity = {
|
|||
dependsOn?: string[];
|
||||
};
|
||||
|
||||
const registry: MigrationEntity[] = [v00001];
|
||||
const registry: MigrationEntity[] = [v00001, v00002];
|
||||
|
||||
export const runMigrations = async () => {
|
||||
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const migrateTag = async (
|
|||
scheduleName: string,
|
||||
): Promise<string | null> => {
|
||||
const repoUrl = buildRepoUrl(repository.config);
|
||||
const env = await buildEnv(repository.config);
|
||||
const env = await buildEnv(repository.config, repository.organizationId);
|
||||
|
||||
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../../db/db";
|
||||
import { organization, repositoriesTable, volumesTable, notificationDestinationsTable } from "../../../db/schema";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { cryptoUtils } from "~/server/utils/crypto";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { BackendConfig } from "~/schemas/volumes";
|
||||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
||||
|
||||
/**
|
||||
* Migration: Isolate Restic Passwords
|
||||
*
|
||||
* This migration performs two critical tasks:
|
||||
* 1. Assigns unique restic passwords to each organization (using the legacy password for existing orgs)
|
||||
* 2. Re-keys all encrypted secrets from the legacy restic passfile to use the new APP_SECRET
|
||||
*
|
||||
* This allows per-organization encryption key isolation while ensuring
|
||||
* database encryption is decoupled from restic repository passwords.
|
||||
*/
|
||||
|
||||
const legacyDecrypt = async (encryptedData: string): Promise<string> => {
|
||||
const keyLength = 32;
|
||||
const algorithm = "aes-256-gcm" as const;
|
||||
|
||||
if (!cryptoUtils.isEncrypted(encryptedData)) {
|
||||
return encryptedData;
|
||||
}
|
||||
|
||||
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
||||
|
||||
const parts = encryptedData.split(":").slice(1); // Remove prefix
|
||||
const saltHex = parts.shift() as string;
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
|
||||
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
|
||||
|
||||
const iv = Buffer.from(parts.shift() as string, "hex");
|
||||
const encrypted = Buffer.from(parts.shift() as string, "hex");
|
||||
const tag = Buffer.from(parts.shift() as string, "hex");
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
let decrypted = decipher.update(encrypted);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString();
|
||||
};
|
||||
|
||||
type MigrationError = { name: string; error: string };
|
||||
|
||||
const rekeySecrets = async (config: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||
const rekeyedConfig: Record<string, unknown> = { ...config };
|
||||
|
||||
for (const [key, value] of Object.entries(rekeyedConfig)) {
|
||||
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
|
||||
const decrypted = await legacyDecrypt(value);
|
||||
rekeyedConfig[key] = await cryptoUtils.sealSecret(decrypted);
|
||||
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
rekeyedConfig[key] = await rekeySecrets(value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
return rekeyedConfig;
|
||||
};
|
||||
|
||||
const execute = async () => {
|
||||
const errors: MigrationError[] = [];
|
||||
|
||||
// Step 1: Read the legacy restic passfile
|
||||
const legacyPassword = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
||||
|
||||
if (!legacyPassword) {
|
||||
logger.info("No legacy restic passfile found, skipping migration");
|
||||
return { success: true, errors: [] };
|
||||
}
|
||||
|
||||
// Step 2: Assign restic passwords to all existing organizations
|
||||
const organizations = await db.query.organization.findMany({});
|
||||
|
||||
for (const org of organizations) {
|
||||
try {
|
||||
const currentMetadata = org.metadata;
|
||||
|
||||
if (!currentMetadata?.resticPassword) {
|
||||
const newMetadata = {
|
||||
...currentMetadata,
|
||||
resticPassword: await cryptoUtils.sealSecret(legacyPassword),
|
||||
};
|
||||
|
||||
await db.update(organization).set({ metadata: newMetadata }).where(eq(organization.id, org.id));
|
||||
|
||||
logger.info(`Assigned restic password to organization: ${org.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ name: `org:${org.name}`, error: toMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Re-key all repository secrets
|
||||
const repositories = await db.query.repositoriesTable.findMany({});
|
||||
|
||||
for (const repo of repositories) {
|
||||
try {
|
||||
const rekeyedConfig = (await rekeySecrets(repo.config)) as RepositoryConfig;
|
||||
|
||||
await db.update(repositoriesTable).set({ config: rekeyedConfig }).where(eq(repositoriesTable.id, repo.id));
|
||||
|
||||
logger.info(`Re-keyed secrets for repository: ${repo.name}`);
|
||||
} catch (err) {
|
||||
errors.push({ name: `repo:${repo.name}`, error: toMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Re-key all volume secrets
|
||||
const volumes = await db.query.volumesTable.findMany({});
|
||||
|
||||
for (const volume of volumes) {
|
||||
try {
|
||||
const rekeyedConfig = (await rekeySecrets(volume.config)) as BackendConfig;
|
||||
|
||||
await db.update(volumesTable).set({ config: rekeyedConfig }).where(eq(volumesTable.id, volume.id));
|
||||
|
||||
logger.info(`Re-keyed secrets for volume: ${volume.name}`);
|
||||
} catch (err) {
|
||||
errors.push({ name: `volume:${volume.name}`, error: toMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Re-key all notification secrets
|
||||
const notifications = await db.query.notificationDestinationsTable.findMany({});
|
||||
|
||||
for (const notification of notifications) {
|
||||
try {
|
||||
const rekeyedConfig = (await rekeySecrets(notification.config)) as NotificationConfig;
|
||||
|
||||
await db
|
||||
.update(notificationDestinationsTable)
|
||||
.set({ config: rekeyedConfig })
|
||||
.where(eq(notificationDestinationsTable.id, notification.id));
|
||||
|
||||
logger.info(`Re-keyed secrets for notification: ${notification.name}`);
|
||||
} catch (err) {
|
||||
errors.push({ name: `notification:${notification.name}`, error: toMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
};
|
||||
|
||||
export const v00002 = {
|
||||
execute,
|
||||
id: "00002-isolate-restic-passwords",
|
||||
type: "critical" as const,
|
||||
dependsOn: ["00001-retag-snapshots"],
|
||||
};
|
||||
|
|
@ -3,7 +3,6 @@ import { and, eq, or } from "drizzle-orm";
|
|||
import { db } from "../../db/db";
|
||||
import { backupSchedulesTable, volumesTable } from "../../db/schema";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
||||
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
||||
|
|
@ -15,29 +14,36 @@ import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
|||
import { cache } from "~/server/utils/cache";
|
||||
import { initAuth } from "~/lib/auth";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { withContext } from "~/server/core/request-context";
|
||||
|
||||
const ensureLatestConfigurationSchema = async () => {
|
||||
const volumes = await db.query.volumesTable.findMany({});
|
||||
|
||||
for (const volume of volumes) {
|
||||
await volumeService.updateVolume(volume.name, volume).catch((err) => {
|
||||
logger.error(`Failed to update volume ${volume.name}: ${err}`);
|
||||
await withContext({ organizationId: volume.organizationId }, async () => {
|
||||
await volumeService.updateVolume(volume.id, volume).catch((err) => {
|
||||
logger.error(`Failed to update volume ${volume.name}: ${err}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const repositories = await db.query.repositoriesTable.findMany({});
|
||||
|
||||
for (const repo of repositories) {
|
||||
await repositoriesService.updateRepository(repo.id, {}).catch((err) => {
|
||||
logger.error(`Failed to update repository ${repo.name}: ${err}`);
|
||||
await withContext({ organizationId: repo.organizationId }, async () => {
|
||||
await repositoriesService.updateRepository(repo.id, {}).catch((err) => {
|
||||
logger.error(`Failed to update repository ${repo.name}: ${err}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const notifications = await db.query.notificationDestinationsTable.findMany({});
|
||||
|
||||
for (const notification of notifications) {
|
||||
await notificationsService.updateDestination(notification.id, notification).catch((err) => {
|
||||
logger.error(`Failed to update notification destination ${notification.id}: ${err}`);
|
||||
await withContext({ organizationId: notification.organizationId }, async () => {
|
||||
await notificationsService.updateDestination(notification.id, notification).catch((err) => {
|
||||
logger.error(`Failed to update notification destination ${notification.id}: ${err}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -48,10 +54,6 @@ export const startup = async () => {
|
|||
await Scheduler.start();
|
||||
await Scheduler.clear();
|
||||
|
||||
await restic.ensurePassfile().catch((err) => {
|
||||
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
|
||||
});
|
||||
|
||||
await initAuth().catch((err) => {
|
||||
logger.error(`Error initializing auth: ${toMessage(err)}`);
|
||||
throw err;
|
||||
|
|
@ -67,8 +69,10 @@ export const startup = async () => {
|
|||
});
|
||||
|
||||
for (const volume of volumes) {
|
||||
await volumeService.mountVolume(volume.name).catch((err) => {
|
||||
logger.error(`Error auto-remounting volume ${volume.name} on startup: ${err.message}`);
|
||||
await withContext({ organizationId: volume.organizationId }, async () => {
|
||||
await volumeService.mountVolume(volume.id).catch((err) => {
|
||||
logger.error(`Error auto-remounting volume ${volume.name} on startup: ${err.message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,17 +14,21 @@ import { buildShoutrrrUrl } from "./builders";
|
|||
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { type } from "arktype";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
|
||||
const listDestinations = async () => {
|
||||
const organizationId = getOrganizationId();
|
||||
const destinations = await db.query.notificationDestinationsTable.findMany({
|
||||
where: eq(notificationDestinationsTable.organizationId, organizationId),
|
||||
orderBy: (destinations, { asc }) => [asc(destinations.name)],
|
||||
});
|
||||
return destinations;
|
||||
};
|
||||
|
||||
const getDestination = async (id: number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const destination = await db.query.notificationDestinationsTable.findFirst({
|
||||
where: eq(notificationDestinationsTable.id, id),
|
||||
where: and(eq(notificationDestinationsTable.id, id), eq(notificationDestinationsTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (!destination) {
|
||||
|
|
@ -133,10 +137,11 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
|
|||
}
|
||||
|
||||
const createDestination = async (name: string, config: NotificationConfig) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const slug = slugify(name, { lower: true, strict: true });
|
||||
|
||||
const existing = await db.query.notificationDestinationsTable.findFirst({
|
||||
where: eq(notificationDestinationsTable.name, slug),
|
||||
where: and(eq(notificationDestinationsTable.name, slug), eq(notificationDestinationsTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -151,6 +156,7 @@ const createDestination = async (name: string, config: NotificationConfig) => {
|
|||
name: slug,
|
||||
type: config.type,
|
||||
config: encryptedConfig,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -165,6 +171,7 @@ const updateDestination = async (
|
|||
id: number,
|
||||
updates: { name?: string; enabled?: boolean; config?: NotificationConfig },
|
||||
) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const existing = await getDestination(id);
|
||||
|
||||
if (!existing) {
|
||||
|
|
@ -179,7 +186,7 @@ const updateDestination = async (
|
|||
const slug = slugify(updates.name, { lower: true, strict: true });
|
||||
|
||||
const conflict = await db.query.notificationDestinationsTable.findFirst({
|
||||
where: and(eq(notificationDestinationsTable.name, slug), ne(notificationDestinationsTable.id, id)),
|
||||
where: and(eq(notificationDestinationsTable.name, slug), ne(notificationDestinationsTable.id, id), eq(notificationDestinationsTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
|
|
@ -215,7 +222,11 @@ const updateDestination = async (
|
|||
};
|
||||
|
||||
const deleteDestination = async (id: number) => {
|
||||
await db.delete(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id));
|
||||
const organizationId = getOrganizationId();
|
||||
await getDestination(id);
|
||||
await db
|
||||
.delete(notificationDestinationsTable)
|
||||
.where(and(eq(notificationDestinationsTable.id, id), eq(notificationDestinationsTable.organizationId, organizationId)));
|
||||
};
|
||||
|
||||
const testDestination = async (id: number) => {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ export const repositoriesController = new Hono()
|
|||
.get("/:id/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { backupId } = c.req.valid("query");
|
||||
|
||||
const res = await repositoriesService.listSnapshots(id, backupId);
|
||||
|
||||
const snapshots = res.map((snapshot) => {
|
||||
|
|
@ -147,21 +146,18 @@ export const repositoriesController = new Hono()
|
|||
.post("/:id/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { snapshotId, ...options } = c.req.valid("json");
|
||||
|
||||
const result = await repositoriesService.restoreSnapshot(id, snapshotId, options);
|
||||
|
||||
return c.json<RestoreSnapshotDto>(result, 200);
|
||||
})
|
||||
.post("/:id/doctor", doctorRepositoryDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
|
||||
const result = await repositoriesService.doctorRepository(id);
|
||||
|
||||
return c.json<DoctorRepositoryDto>(result, 200);
|
||||
})
|
||||
.delete("/:id/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
|
||||
const { id, snapshotId } = c.req.param();
|
||||
|
||||
await repositoriesService.deleteSnapshot(id, snapshotId);
|
||||
|
||||
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
|
||||
|
|
@ -169,7 +165,6 @@ export const repositoriesController = new Hono()
|
|||
.delete("/:id/snapshots", deleteSnapshotsDto, validator("json", deleteSnapshotsBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { snapshotIds } = c.req.valid("json");
|
||||
|
||||
await repositoriesService.deleteSnapshots(id, snapshotIds);
|
||||
|
||||
return c.json<DeleteSnapshotsResponseDto>({ message: "Snapshots deleted" }, 200);
|
||||
|
|
@ -177,7 +172,6 @@ export const repositoriesController = new Hono()
|
|||
.post("/:id/snapshots/tag", tagSnapshotsDto, validator("json", tagSnapshotsBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { snapshotIds, ...tags } = c.req.valid("json");
|
||||
|
||||
await repositoriesService.tagSnapshots(id, snapshotIds, tags);
|
||||
|
||||
return c.json<TagSnapshotsResponseDto>({ message: "Snapshots tagged" }, 200);
|
||||
|
|
@ -185,7 +179,6 @@ export const repositoriesController = new Hono()
|
|||
.patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const res = await repositoriesService.updateRepository(id, body);
|
||||
|
||||
return c.json<UpdateRepositoryDto>(res.repository, 200);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import crypto from "node:crypto";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||
import { db } from "../../db/db";
|
||||
import { repositoriesTable } from "../../db/schema";
|
||||
|
|
@ -9,22 +9,30 @@ import { restic } from "../../utils/restic";
|
|||
import { cryptoUtils } from "../../utils/crypto";
|
||||
import { cache } from "../../utils/cache";
|
||||
import { repoMutex } from "../../core/repository-mutex";
|
||||
import { type } from "arktype";
|
||||
import {
|
||||
repositoryConfigSchema,
|
||||
type CompressionMode,
|
||||
type OverwriteMode,
|
||||
type RepositoryConfig,
|
||||
} from "~/schemas/restic";
|
||||
import { type } from "arktype";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
|
||||
const findRepository = async (idOrShortId: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
return await db.query.repositoriesTable.findFirst({
|
||||
where: or(eq(repositoriesTable.id, idOrShortId), eq(repositoriesTable.shortId, idOrShortId)),
|
||||
where: and(
|
||||
or(eq(repositoriesTable.id, idOrShortId), eq(repositoriesTable.shortId, idOrShortId)),
|
||||
eq(repositoriesTable.organizationId, organizationId),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const listRepositories = async () => {
|
||||
const repositories = await db.query.repositoriesTable.findMany({});
|
||||
const organizationId = getOrganizationId();
|
||||
const repositories = await db.query.repositoriesTable.findMany({
|
||||
where: eq(repositoriesTable.organizationId, organizationId),
|
||||
});
|
||||
return repositories;
|
||||
};
|
||||
|
||||
|
|
@ -68,6 +76,7 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||
};
|
||||
|
||||
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const id = crypto.randomUUID();
|
||||
const shortId = generateShortId();
|
||||
|
||||
|
|
@ -88,6 +97,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
config: encryptedConfig,
|
||||
compressionMode: compressionMode ?? "auto",
|
||||
status: "unknown",
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -99,13 +109,13 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
|
||||
if (config.isExistingRepository) {
|
||||
const result = await restic
|
||||
.snapshots(encryptedConfig)
|
||||
.snapshots(encryptedConfig, { organizationId })
|
||||
.then(() => ({ error: null }))
|
||||
.catch((error) => ({ error }));
|
||||
|
||||
error = result.error;
|
||||
} else {
|
||||
const initResult = await restic.init(encryptedConfig);
|
||||
const initResult = await restic.init(encryptedConfig, organizationId);
|
||||
error = initResult.error;
|
||||
}
|
||||
|
||||
|
|
@ -113,13 +123,15 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
await db
|
||||
.update(repositoriesTable)
|
||||
.set({ status: "healthy", lastChecked: Date.now(), lastError: null })
|
||||
.where(eq(repositoriesTable.id, id));
|
||||
.where(and(eq(repositoriesTable.id, id), eq(repositoriesTable.organizationId, organizationId)));
|
||||
|
||||
return { repository: created, status: 201 };
|
||||
}
|
||||
|
||||
const errorMessage = toMessage(error);
|
||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
|
||||
await db
|
||||
.delete(repositoriesTable)
|
||||
.where(and(eq(repositoriesTable.id, id), eq(repositoriesTable.organizationId, organizationId)));
|
||||
|
||||
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
|
||||
};
|
||||
|
|
@ -143,7 +155,11 @@ const deleteRepository = async (id: string) => {
|
|||
|
||||
// TODO: Add cleanup logic for the actual restic repository files
|
||||
|
||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, repository.id));
|
||||
await db
|
||||
.delete(repositoriesTable)
|
||||
.where(
|
||||
and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)),
|
||||
);
|
||||
|
||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||
cache.delByPrefix(`ls:${repository.id}:`);
|
||||
|
|
@ -158,6 +174,7 @@ const deleteRepository = async (id: string) => {
|
|||
* @returns List of snapshots
|
||||
*/
|
||||
const listSnapshots = async (id: string, backupId?: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -175,9 +192,9 @@ const listSnapshots = async (id: string, backupId?: string) => {
|
|||
let snapshots = [];
|
||||
|
||||
if (backupId) {
|
||||
snapshots = await restic.snapshots(repository.config, { tags: [backupId] });
|
||||
snapshots = await restic.snapshots(repository.config, { tags: [backupId], organizationId });
|
||||
} else {
|
||||
snapshots = await restic.snapshots(repository.config);
|
||||
snapshots = await restic.snapshots(repository.config, { organizationId });
|
||||
}
|
||||
|
||||
cache.set(cacheKey, snapshots);
|
||||
|
|
@ -189,6 +206,7 @@ const listSnapshots = async (id: string, backupId?: string) => {
|
|||
};
|
||||
|
||||
const listSnapshotFiles = async (id: string, snapshotId: string, path?: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -206,7 +224,7 @@ const listSnapshotFiles = async (id: string, snapshotId: string, path?: string)
|
|||
|
||||
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
|
||||
try {
|
||||
const result = await restic.ls(repository.config, snapshotId, path);
|
||||
const result = await restic.ls(repository.config, snapshotId, organizationId, path);
|
||||
|
||||
if (!result.snapshot) {
|
||||
throw new NotFoundError("Snapshot not found or empty");
|
||||
|
|
@ -243,6 +261,7 @@ const restoreSnapshot = async (
|
|||
overwrite?: OverwriteMode;
|
||||
},
|
||||
) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -253,7 +272,7 @@ const restoreSnapshot = async (
|
|||
|
||||
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
|
||||
try {
|
||||
const result = await restic.restore(repository.config, snapshotId, target, options);
|
||||
const result = await restic.restore(repository.config, snapshotId, target, { ...options, organizationId });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -267,6 +286,7 @@ const restoreSnapshot = async (
|
|||
};
|
||||
|
||||
const getSnapshotDetails = async (id: string, snapshotId: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -279,7 +299,7 @@ const getSnapshotDetails = async (id: string, snapshotId: string) => {
|
|||
if (!snapshots) {
|
||||
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
|
||||
try {
|
||||
snapshots = await restic.snapshots(repository.config);
|
||||
snapshots = await restic.snapshots(repository.config, { organizationId });
|
||||
cache.set(cacheKey, snapshots);
|
||||
} finally {
|
||||
releaseLock();
|
||||
|
|
@ -296,6 +316,7 @@ const getSnapshotDetails = async (id: string, snapshotId: string) => {
|
|||
};
|
||||
|
||||
const checkHealth = async (repositoryId: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(repositoryId);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -304,7 +325,7 @@ const checkHealth = async (repositoryId: string) => {
|
|||
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
|
||||
try {
|
||||
const { hasErrors, error } = await restic.check(repository.config);
|
||||
const { hasErrors, error } = await restic.check(repository.config, { organizationId });
|
||||
|
||||
await db
|
||||
.update(repositoriesTable)
|
||||
|
|
@ -313,7 +334,9 @@ const checkHealth = async (repositoryId: string) => {
|
|||
lastChecked: Date.now(),
|
||||
lastError: error,
|
||||
})
|
||||
.where(eq(repositoriesTable.id, repository.id));
|
||||
.where(
|
||||
and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)),
|
||||
);
|
||||
|
||||
return { lastError: error };
|
||||
} finally {
|
||||
|
|
@ -322,6 +345,7 @@ const checkHealth = async (repositoryId: string) => {
|
|||
};
|
||||
|
||||
const doctorRepository = async (id: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -330,7 +354,7 @@ const doctorRepository = async (id: string) => {
|
|||
|
||||
const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = [];
|
||||
|
||||
const unlockResult = await restic.unlock(repository.config).then(
|
||||
const unlockResult = await restic.unlock(repository.config, organizationId).then(
|
||||
(result) => ({ success: true, message: result.message, error: null }),
|
||||
(error) => ({ success: false, message: null, error: toMessage(error) }),
|
||||
);
|
||||
|
|
@ -344,7 +368,7 @@ const doctorRepository = async (id: string) => {
|
|||
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor");
|
||||
try {
|
||||
const checkResult = await restic.check(repository.config, { readData: false }).then(
|
||||
const checkResult = await restic.check(repository.config, { readData: false, organizationId }).then(
|
||||
(result) => result,
|
||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||
);
|
||||
|
|
@ -357,7 +381,7 @@ const doctorRepository = async (id: string) => {
|
|||
});
|
||||
|
||||
if (checkResult.hasErrors) {
|
||||
const repairResult = await restic.repairIndex(repository.config).then(
|
||||
const repairResult = await restic.repairIndex(repository.config, organizationId).then(
|
||||
(result) => ({ success: true, output: result.output, error: null }),
|
||||
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
||||
);
|
||||
|
|
@ -369,7 +393,7 @@ const doctorRepository = async (id: string) => {
|
|||
error: repairResult.error,
|
||||
});
|
||||
|
||||
const recheckResult = await restic.check(repository.config, { readData: false }).then(
|
||||
const recheckResult = await restic.check(repository.config, { readData: false, organizationId }).then(
|
||||
(result) => result,
|
||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||
);
|
||||
|
|
@ -402,7 +426,9 @@ const doctorRepository = async (id: string) => {
|
|||
lastChecked: Date.now(),
|
||||
lastError: doctorError,
|
||||
})
|
||||
.where(eq(repositoriesTable.id, repository.id));
|
||||
.where(
|
||||
and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)),
|
||||
);
|
||||
|
||||
return {
|
||||
success: doctorSucceeded,
|
||||
|
|
@ -411,6 +437,7 @@ const doctorRepository = async (id: string) => {
|
|||
};
|
||||
|
||||
const deleteSnapshot = async (id: string, snapshotId: string) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -419,7 +446,7 @@ const deleteSnapshot = async (id: string, snapshotId: string) => {
|
|||
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
|
||||
try {
|
||||
await restic.deleteSnapshot(repository.config, snapshotId);
|
||||
await restic.deleteSnapshot(repository.config, snapshotId, organizationId);
|
||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
||||
} finally {
|
||||
|
|
@ -428,6 +455,7 @@ const deleteSnapshot = async (id: string, snapshotId: string) => {
|
|||
};
|
||||
|
||||
const deleteSnapshots = async (id: string, snapshotIds: string[]) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -436,7 +464,7 @@ const deleteSnapshots = async (id: string, snapshotIds: string[]) => {
|
|||
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
|
||||
try {
|
||||
await restic.deleteSnapshots(repository.config, snapshotIds);
|
||||
await restic.deleteSnapshots(repository.config, snapshotIds, organizationId);
|
||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||
for (const snapshotId of snapshotIds) {
|
||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
||||
|
|
@ -451,6 +479,7 @@ const tagSnapshots = async (
|
|||
snapshotIds: string[],
|
||||
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
||||
) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
|
|
@ -459,7 +488,7 @@ const tagSnapshots = async (
|
|||
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
|
||||
try {
|
||||
await restic.tagSnapshots(repository.config, snapshotIds, tags);
|
||||
await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId);
|
||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||
for (const snapshotId of snapshotIds) {
|
||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
||||
|
|
|
|||
|
|
@ -7,14 +7,30 @@ import {
|
|||
systemInfoDto,
|
||||
type SystemInfoDto,
|
||||
type UpdateInfoDto,
|
||||
setRegistrationStatusDto,
|
||||
getRegistrationStatusDto,
|
||||
registrationStatusBody,
|
||||
type RegistrationStatusDto,
|
||||
} from "./system.dto";
|
||||
import { systemService } from "./system.service";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
|
||||
import { db } from "../../db/db";
|
||||
import { usersTable } from "../../db/schema";
|
||||
import { organization, usersTable } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { verifyUserPassword } from "../auth/helpers";
|
||||
import { cryptoUtils } from "../../utils/crypto";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
|
||||
const requireGlobalAdmin = createMiddleware(async (c, next) => {
|
||||
const user = c.get("user");
|
||||
|
||||
if (!user || user.role !== "admin") {
|
||||
return c.json({ message: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
export const systemController = new Hono()
|
||||
.use(requireAuth)
|
||||
|
|
@ -28,12 +44,32 @@ export const systemController = new Hono()
|
|||
|
||||
return c.json<UpdateInfoDto>(updates, 200);
|
||||
})
|
||||
.get("/registration-status", getRegistrationStatusDto, async (c) => {
|
||||
const enabled = await systemService.isRegistrationEnabled();
|
||||
|
||||
return c.json<RegistrationStatusDto>({ enabled }, 200);
|
||||
})
|
||||
.put(
|
||||
"/registration-status",
|
||||
requireGlobalAdmin,
|
||||
setRegistrationStatusDto,
|
||||
validator("json", registrationStatusBody),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
await systemService.setRegistrationEnabled(body.enabled);
|
||||
|
||||
return c.json<RegistrationStatusDto>({ enabled: body.enabled }, 200);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/restic-password",
|
||||
requireOrgAdmin,
|
||||
downloadResticPasswordDto,
|
||||
validator("json", downloadResticPasswordBodySchema),
|
||||
async (c) => {
|
||||
const user = c.get("user");
|
||||
const organizationId = getOrganizationId();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const isPasswordValid = await verifyUserPassword({ password: body.password, userId: user.id });
|
||||
|
|
@ -42,8 +78,15 @@ export const systemController = new Hono()
|
|||
}
|
||||
|
||||
try {
|
||||
const file = Bun.file(RESTIC_PASS_FILE);
|
||||
const content = await file.text();
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, organizationId),
|
||||
});
|
||||
|
||||
if (!org?.metadata?.resticPassword) {
|
||||
return c.json({ message: "Organization Restic password not found" }, 404);
|
||||
}
|
||||
|
||||
const content = await cryptoUtils.resolveSecret(org.metadata.resticPassword);
|
||||
|
||||
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, user.id));
|
||||
|
||||
|
|
@ -52,7 +95,7 @@ export const systemController = new Hono()
|
|||
|
||||
return c.text(content);
|
||||
} catch (_error) {
|
||||
return c.json({ message: "Failed to read Restic password file" }, 500);
|
||||
return c.json({ message: "Failed to retrieve Restic password" }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,12 +65,13 @@ export const downloadResticPasswordBodySchema = type({
|
|||
});
|
||||
|
||||
export const downloadResticPasswordDto = describeRoute({
|
||||
description: "Download the Restic password file for backup recovery. Requires password re-authentication.",
|
||||
description:
|
||||
"Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.",
|
||||
tags: ["System"],
|
||||
operationId: "downloadResticPassword",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Restic password file content",
|
||||
description: "Organization's Restic password",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
|
|
@ -79,3 +80,45 @@ export const downloadResticPasswordDto = describeRoute({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const registrationStatusResponse = type({
|
||||
enabled: "boolean",
|
||||
});
|
||||
|
||||
export type RegistrationStatusDto = typeof registrationStatusResponse.infer;
|
||||
|
||||
export const registrationStatusBody = type({
|
||||
enabled: "boolean",
|
||||
});
|
||||
|
||||
export const getRegistrationStatusDto = describeRoute({
|
||||
description: "Get the current registration status for new users",
|
||||
tags: ["System"],
|
||||
operationId: "getRegistrationStatus",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registration status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(registrationStatusResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const setRegistrationStatusDto = describeRoute({
|
||||
description: "Update the registration status for new users. Requires global admin role.",
|
||||
tags: ["System"],
|
||||
operationId: "setRegistrationStatus",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registration status updated",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(registrationStatusResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import type { UpdateInfoDto } from "./system.dto";
|
|||
import semver from "semver";
|
||||
import { cache } from "../../utils/cache";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { db } from "../../db/db";
|
||||
import { appMetadataTable } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants";
|
||||
|
||||
const CACHE_TTL = 60 * 60;
|
||||
|
||||
|
|
@ -69,12 +73,7 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
|
|||
? []
|
||||
: formattedReleases.filter((r) => !!(semver.valid(r.version) && semver.gt(r.version, currentVersion)));
|
||||
|
||||
const data: UpdateInfoDto = {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
hasUpdate,
|
||||
missedReleases,
|
||||
};
|
||||
const data = { currentVersion, latestVersion, hasUpdate, missedReleases };
|
||||
|
||||
cache.set(CACHE_KEY, data, CACHE_TTL);
|
||||
|
||||
|
|
@ -90,7 +89,28 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
|
|||
}
|
||||
};
|
||||
|
||||
const isRegistrationEnabled = async () => {
|
||||
const result = await db.query.appMetadataTable.findFirst({
|
||||
where: eq(appMetadataTable.key, REGISTRATION_ENABLED_KEY),
|
||||
});
|
||||
|
||||
return result?.value === "true";
|
||||
};
|
||||
|
||||
const setRegistrationEnabled = async (enabled: boolean) => {
|
||||
const now = Date.now();
|
||||
|
||||
await db
|
||||
.insert(appMetadataTable)
|
||||
.values({ key: REGISTRATION_ENABLED_KEY, value: JSON.stringify(enabled), createdAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({ target: appMetadataTable.key, set: { value: JSON.stringify(enabled), updatedAt: now } });
|
||||
|
||||
logger.info(`Registration enabled set to: ${enabled}`);
|
||||
};
|
||||
|
||||
export const systemService = {
|
||||
getSystemInfo,
|
||||
getUpdates,
|
||||
isRegistrationEnabled,
|
||||
setRegistrationEnabled,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,15 +50,15 @@ export const volumeController = new Hono()
|
|||
|
||||
return c.json(result, 200);
|
||||
})
|
||||
.delete("/:name", deleteVolumeDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
await volumeService.deleteVolume(name);
|
||||
.delete("/:id", deleteVolumeDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
await volumeService.deleteVolume(id);
|
||||
|
||||
return c.json({ message: "Volume deleted" }, 200);
|
||||
})
|
||||
.get("/:name", getVolumeDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const res = await volumeService.getVolume(name);
|
||||
.get("/:id", getVolumeDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const res = await volumeService.getVolume(id);
|
||||
|
||||
const response = {
|
||||
volume: {
|
||||
|
|
@ -74,10 +74,10 @@ export const volumeController = new Hono()
|
|||
|
||||
return c.json<GetVolumeDto>(response, 200);
|
||||
})
|
||||
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
|
||||
const { name } = c.req.param();
|
||||
.put("/:id", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const body = c.req.valid("json");
|
||||
const res = await volumeService.updateVolume(name, body);
|
||||
const res = await volumeService.updateVolume(id, body);
|
||||
|
||||
const response = {
|
||||
...res.volume,
|
||||
|
|
@ -86,28 +86,28 @@ export const volumeController = new Hono()
|
|||
|
||||
return c.json<UpdateVolumeDto>(response, 200);
|
||||
})
|
||||
.post("/:name/mount", mountVolumeDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const { error, status } = await volumeService.mountVolume(name);
|
||||
.post("/:id/mount", mountVolumeDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { error, status } = await volumeService.mountVolume(id);
|
||||
|
||||
return c.json({ error, status }, error ? 500 : 200);
|
||||
})
|
||||
.post("/:name/unmount", unmountVolumeDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const { error, status } = await volumeService.unmountVolume(name);
|
||||
.post("/:id/unmount", unmountVolumeDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { error, status } = await volumeService.unmountVolume(id);
|
||||
|
||||
return c.json({ error, status }, error ? 500 : 200);
|
||||
})
|
||||
.post("/:name/health-check", healthCheckDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const { error, status } = await volumeService.checkHealth(name);
|
||||
.post("/:id/health-check", healthCheckDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { error, status } = await volumeService.checkHealth(id);
|
||||
|
||||
return c.json({ error, status }, 200);
|
||||
})
|
||||
.get("/:name/files", listFilesDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
.get("/:id/files", listFilesDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const subPath = c.req.query("path");
|
||||
const result = await volumeService.listFiles(name, subPath);
|
||||
const result = await volumeService.listFiles(id, subPath);
|
||||
|
||||
const response = {
|
||||
files: result.files,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { logger } from "../../utils/logger";
|
|||
import { serverEvents } from "../../core/events";
|
||||
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
||||
import { type } from "arktype";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
|
||||
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
|
||||
switch (config.backend) {
|
||||
|
|
@ -43,16 +44,31 @@ async function encryptSensitiveFields(config: BackendConfig): Promise<BackendCon
|
|||
}
|
||||
|
||||
const listVolumes = async () => {
|
||||
const volumes = await db.query.volumesTable.findMany({});
|
||||
const organizationId = getOrganizationId();
|
||||
const volumes = await db.query.volumesTable.findMany({
|
||||
where: eq(volumesTable.organizationId, organizationId),
|
||||
});
|
||||
|
||||
return volumes;
|
||||
};
|
||||
|
||||
const findVolume = async (idOrShortId: string | number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const isNumeric = typeof idOrShortId === "number" || /^\d+$/.test(String(idOrShortId));
|
||||
return await db.query.volumesTable.findFirst({
|
||||
where: and(
|
||||
isNumeric ? eq(volumesTable.id, Number(idOrShortId)) : eq(volumesTable.shortId, idOrShortId),
|
||||
eq(volumesTable.organizationId, organizationId),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const slug = slugify(name, { lower: true, strict: true });
|
||||
|
||||
const existing = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, slug),
|
||||
where: and(eq(volumesTable.name, slug), eq(volumesTable.organizationId, organizationId)),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -69,6 +85,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||
name: slug,
|
||||
config: encryptedConfig,
|
||||
type: backendConfig.backend,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -82,15 +99,14 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||
await db
|
||||
.update(volumesTable)
|
||||
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
||||
.where(eq(volumesTable.name, slug));
|
||||
.where(and(eq(volumesTable.id, created.id), eq(volumesTable.organizationId, organizationId)));
|
||||
|
||||
return { volume: created, status: 201 };
|
||||
};
|
||||
|
||||
const deleteVolume = async (name: string) => {
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
const deleteVolume = async (idOrShortId: string | number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const volume = await findVolume(idOrShortId);
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
|
|
@ -98,13 +114,14 @@ const deleteVolume = async (name: string) => {
|
|||
|
||||
const backend = createVolumeBackend(volume);
|
||||
await backend.unmount();
|
||||
await db.delete(volumesTable).where(eq(volumesTable.name, name));
|
||||
await db
|
||||
.delete(volumesTable)
|
||||
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
||||
};
|
||||
|
||||
const mountVolume = async (name: string) => {
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
const mountVolume = async (idOrShortId: string | number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const volume = await findVolume(idOrShortId);
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
|
|
@ -116,19 +133,18 @@ const mountVolume = async (name: string) => {
|
|||
await db
|
||||
.update(volumesTable)
|
||||
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
||||
.where(eq(volumesTable.name, name));
|
||||
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
||||
|
||||
if (status === "mounted") {
|
||||
serverEvents.emit("volume:mounted", { volumeName: name });
|
||||
serverEvents.emit("volume:mounted", { volumeName: volume.name });
|
||||
}
|
||||
|
||||
return { error, status };
|
||||
};
|
||||
|
||||
const unmountVolume = async (name: string) => {
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
const unmountVolume = async (idOrShortId: string | number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const volume = await findVolume(idOrShortId);
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
|
|
@ -137,19 +153,20 @@ const unmountVolume = async (name: string) => {
|
|||
const backend = createVolumeBackend(volume);
|
||||
const { status, error } = await backend.unmount();
|
||||
|
||||
await db.update(volumesTable).set({ status }).where(eq(volumesTable.name, name));
|
||||
await db
|
||||
.update(volumesTable)
|
||||
.set({ status })
|
||||
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
||||
|
||||
if (status === "unmounted") {
|
||||
serverEvents.emit("volume:unmounted", { volumeName: name });
|
||||
serverEvents.emit("volume:unmounted", { volumeName: volume.name });
|
||||
}
|
||||
|
||||
return { error, status };
|
||||
};
|
||||
|
||||
const getVolume = async (name: string) => {
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
const getVolume = async (idOrShortId: string | number) => {
|
||||
const volume = await findVolume(idOrShortId);
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
|
|
@ -158,7 +175,7 @@ const getVolume = async (name: string) => {
|
|||
let statfs: Partial<StatFs> = {};
|
||||
if (volume.status === "mounted") {
|
||||
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
|
||||
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||
logger.warn(`Failed to get statfs for volume ${volume.name}: ${toMessage(error)}`);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
|
@ -166,10 +183,9 @@ const getVolume = async (name: string) => {
|
|||
return { volume, statfs };
|
||||
};
|
||||
|
||||
const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
||||
const existing = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
const updateVolume = async (idOrShortId: string | number, volumeData: UpdateVolumeBody) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const existing = await findVolume(idOrShortId);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
|
|
@ -180,7 +196,11 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||
const newSlug = slugify(volumeData.name, { lower: true, strict: true });
|
||||
|
||||
const conflict = await db.query.volumesTable.findFirst({
|
||||
where: and(eq(volumesTable.name, newSlug), ne(volumesTable.id, existing.id)),
|
||||
where: and(
|
||||
eq(volumesTable.name, newSlug),
|
||||
ne(volumesTable.id, existing.id),
|
||||
eq(volumesTable.organizationId, organizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
|
|
@ -215,7 +235,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||
autoRemount: volumeData.autoRemount,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(volumesTable.id, existing.id))
|
||||
.where(and(eq(volumesTable.id, existing.id), eq(volumesTable.organizationId, organizationId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
|
|
@ -228,7 +248,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||
await db
|
||||
.update(volumesTable)
|
||||
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
||||
.where(eq(volumesTable.id, existing.id));
|
||||
.where(and(eq(volumesTable.id, existing.id), eq(volumesTable.organizationId, organizationId)));
|
||||
|
||||
serverEvents.emit("volume:updated", { volumeName: updated.name });
|
||||
}
|
||||
|
|
@ -252,6 +272,7 @@ const testConnection = async (backendConfig: BackendConfig) => {
|
|||
status: "unmounted" as const,
|
||||
lastError: null,
|
||||
autoRemount: true,
|
||||
organizationId: "test-org",
|
||||
};
|
||||
|
||||
const backend = createVolumeBackend(mockVolume);
|
||||
|
|
@ -268,10 +289,9 @@ const testConnection = async (backendConfig: BackendConfig) => {
|
|||
};
|
||||
};
|
||||
|
||||
const checkHealth = async (name: string) => {
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
const checkHealth = async (idOrShortId: string | number) => {
|
||||
const organizationId = getOrganizationId();
|
||||
const volume = await findVolume(idOrShortId);
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
|
|
@ -281,21 +301,19 @@ const checkHealth = async (name: string) => {
|
|||
const { error, status } = await backend.checkHealth();
|
||||
|
||||
if (status !== volume.status) {
|
||||
serverEvents.emit("volume:status_changed", { volumeName: name, status });
|
||||
serverEvents.emit("volume:status_changed", { volumeName: volume.name, status });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(volumesTable)
|
||||
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
|
||||
.where(eq(volumesTable.name, volume.name));
|
||||
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
||||
|
||||
return { status, error };
|
||||
};
|
||||
|
||||
const listFiles = async (name: string, subPath?: string) => {
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
const listFiles = async (idOrShortId: string | number, subPath?: string) => {
|
||||
const volume = await findVolume(idOrShortId);
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||
import { config } from "../core/config";
|
||||
import { isNodeJSErrnoException } from "./fs";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ const resolveFileSecret = async (ref: string): Promise<string> => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Given a string, encrypts it using a randomly generated salt.
|
||||
* Given a string, encrypts it using a randomly generated salt and the APP_SECRET.
|
||||
* Returns the input unchanged if it's empty or already encrypted.
|
||||
*/
|
||||
const encrypt = async (data: string) => {
|
||||
|
|
@ -96,10 +96,8 @@ const encrypt = async (data: string) => {
|
|||
return data;
|
||||
}
|
||||
|
||||
const secret = await Bun.file(RESTIC_PASS_FILE).text();
|
||||
|
||||
const salt = crypto.randomBytes(16);
|
||||
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
|
||||
const key = crypto.pbkdf2Sync(config.appSecret, salt, 100000, keyLength, "sha256");
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
|
|
@ -110,7 +108,7 @@ const encrypt = async (data: string) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Given an encrypted string, decrypts it using the salt stored in the string.
|
||||
* Given an encrypted string, decrypts it using the salt stored in the string and the APP_SECRET.
|
||||
* Returns the input unchanged if it's not encrypted (for backward compatibility).
|
||||
*/
|
||||
const decrypt = async (encryptedData: string) => {
|
||||
|
|
@ -118,13 +116,11 @@ const decrypt = async (encryptedData: string) => {
|
|||
return encryptedData;
|
||||
}
|
||||
|
||||
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
||||
|
||||
const parts = encryptedData.split(":").slice(1); // Remove prefix
|
||||
const saltHex = parts.shift() as string;
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
|
||||
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
|
||||
const key = crypto.pbkdf2Sync(config.appSecret, salt, 100000, keyLength, "sha256");
|
||||
|
||||
const iv = Buffer.from(parts.shift() as string, "hex");
|
||||
const encrypted = Buffer.from(parts.shift() as string, "hex");
|
||||
|
|
@ -187,15 +183,19 @@ const sealSecret = async (value: string): Promise<string> => {
|
|||
};
|
||||
|
||||
async function deriveSecret(label: string) {
|
||||
const masterSecret = await Bun.file(RESTIC_PASS_FILE).text();
|
||||
|
||||
const derivedKey = await hkdf("sha256", masterSecret, "", label, 32);
|
||||
const derivedKey = await hkdf("sha256", config.appSecret, "", label, 32);
|
||||
|
||||
return Buffer.from(derivedKey).toString("hex");
|
||||
}
|
||||
|
||||
function generateResticPassword(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export const cryptoUtils = {
|
||||
resolveSecret,
|
||||
sealSecret,
|
||||
deriveSecret,
|
||||
generateResticPassword,
|
||||
isEncrypted,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import os from "node:os";
|
||||
import { throttle } from "es-toolkit";
|
||||
import { type } from "arktype";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { REPOSITORY_BASE, RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants";
|
||||
import { config as appConfig } from "../core/config";
|
||||
import { logger } from "./logger";
|
||||
|
|
@ -12,6 +13,8 @@ import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
|||
import { safeSpawn, exec } from "./spawn";
|
||||
import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic";
|
||||
import { ResticError } from "./errors";
|
||||
import { db } from "../db/db";
|
||||
import { organization } from "../db/schema";
|
||||
|
||||
const backupOutputSchema = type({
|
||||
message_type: "'summary'",
|
||||
|
|
@ -61,17 +64,6 @@ const snapshotInfoSchema = type({
|
|||
}).optional(),
|
||||
});
|
||||
|
||||
const ensurePassfile = async () => {
|
||||
await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true });
|
||||
|
||||
try {
|
||||
await fs.access(RESTIC_PASS_FILE);
|
||||
} catch {
|
||||
logger.info("Restic passfile not found, creating a new one...");
|
||||
await fs.writeFile(RESTIC_PASS_FILE, crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
||||
}
|
||||
};
|
||||
|
||||
export const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||
switch (config.backend) {
|
||||
case "local":
|
||||
|
|
@ -105,7 +97,7 @@ export const buildRepoUrl = (config: RepositoryConfig): string => {
|
|||
}
|
||||
};
|
||||
|
||||
export const buildEnv = async (config: RepositoryConfig) => {
|
||||
export const buildEnv = async (config: RepositoryConfig, organizationId: string) => {
|
||||
const env: Record<string, string> = {
|
||||
RESTIC_CACHE_DIR,
|
||||
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
||||
|
|
@ -118,7 +110,23 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
|||
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
||||
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
||||
} else {
|
||||
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, organizationId),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error(`Organization ${organizationId} not found`);
|
||||
}
|
||||
|
||||
const metadata = org.metadata;
|
||||
if (!metadata?.resticPassword) {
|
||||
throw new Error(`Restic password not configured for organization ${organizationId}`);
|
||||
} else {
|
||||
const decryptedPassword = await cryptoUtils.resolveSecret(metadata.resticPassword);
|
||||
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
|
||||
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
||||
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
||||
}
|
||||
}
|
||||
|
||||
switch (config.backend) {
|
||||
|
|
@ -219,14 +227,12 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
|||
return env;
|
||||
};
|
||||
|
||||
const init = async (config: RepositoryConfig) => {
|
||||
await ensurePassfile();
|
||||
|
||||
const init = async (config: RepositoryConfig, organizationId: string) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
|
||||
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
||||
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
const args = ["init", "--repo", repoUrl];
|
||||
addCommonArgs(args, env, config);
|
||||
|
|
@ -259,7 +265,8 @@ export type BackupProgress = typeof backupProgressSchema.infer;
|
|||
const backup = async (
|
||||
config: RepositoryConfig,
|
||||
source: string,
|
||||
options?: {
|
||||
options: {
|
||||
organizationId: string;
|
||||
exclude?: string[];
|
||||
excludeIfPresent?: string[];
|
||||
include?: string[];
|
||||
|
|
@ -271,7 +278,7 @@ const backup = async (
|
|||
},
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, options?.organizationId);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"];
|
||||
|
||||
|
|
@ -410,7 +417,8 @@ const restore = async (
|
|||
config: RepositoryConfig,
|
||||
snapshotId: string,
|
||||
target: string,
|
||||
options?: {
|
||||
options: {
|
||||
organizationId: string;
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
excludeXattr?: string[];
|
||||
|
|
@ -419,7 +427,7 @@ const restore = async (
|
|||
},
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
||||
|
||||
|
|
@ -493,11 +501,11 @@ const restore = async (
|
|||
return result;
|
||||
};
|
||||
|
||||
const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => {
|
||||
const { tags } = options;
|
||||
const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => {
|
||||
const { tags, organizationId } = options;
|
||||
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
const args = ["--repo", repoUrl, "snapshots"];
|
||||
|
||||
|
|
@ -527,9 +535,13 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
|||
return result;
|
||||
};
|
||||
|
||||
const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => {
|
||||
const forget = async (
|
||||
config: RepositoryConfig,
|
||||
options: RetentionPolicy,
|
||||
extra: { tag: string; organizationId: string },
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, extra.organizationId);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
|
||||
|
||||
|
|
@ -569,9 +581,9 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||
return { success: true };
|
||||
};
|
||||
|
||||
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
|
||||
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[], organizationId: string) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
if (snapshotIds.length === 0) {
|
||||
throw new Error("No snapshot IDs provided for deletion.");
|
||||
|
|
@ -591,17 +603,18 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[])
|
|||
return { success: true };
|
||||
};
|
||||
|
||||
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||
return deleteSnapshots(config, [snapshotId]);
|
||||
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string, organizationId: string) => {
|
||||
return deleteSnapshots(config, [snapshotId], organizationId);
|
||||
};
|
||||
|
||||
const tagSnapshots = async (
|
||||
config: RepositoryConfig,
|
||||
snapshotIds: string[],
|
||||
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
||||
organizationId: string,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
if (snapshotIds.length === 0) {
|
||||
throw new Error("No snapshot IDs provided for tagging.");
|
||||
|
|
@ -667,9 +680,9 @@ const lsSnapshotInfoSchema = type({
|
|||
message_type: "'snapshot'",
|
||||
});
|
||||
|
||||
const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => {
|
||||
const ls = async (config: RepositoryConfig, snapshotId: string, organizationId: string, path?: string) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
||||
|
||||
|
|
@ -723,9 +736,9 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||
return { snapshot, nodes };
|
||||
};
|
||||
|
||||
const unlock = async (config: RepositoryConfig) => {
|
||||
const unlock = async (config: RepositoryConfig, organizationId: string) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
||||
addCommonArgs(args, env, config);
|
||||
|
|
@ -742,9 +755,9 @@ const unlock = async (config: RepositoryConfig) => {
|
|||
return { success: true, message: "Repository unlocked successfully" };
|
||||
};
|
||||
|
||||
const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => {
|
||||
const check = async (config: RepositoryConfig, options: { readData?: boolean; organizationId: string }) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "check"];
|
||||
|
||||
|
|
@ -780,9 +793,9 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
|||
};
|
||||
};
|
||||
|
||||
const repairIndex = async (config: RepositoryConfig) => {
|
||||
const repairIndex = async (config: RepositoryConfig, organizationId: string) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
const args = ["repair", "index", "--repo", repoUrl];
|
||||
addCommonArgs(args, env, config);
|
||||
|
|
@ -808,16 +821,13 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||
const copy = async (
|
||||
sourceConfig: RepositoryConfig,
|
||||
destConfig: RepositoryConfig,
|
||||
options: {
|
||||
tag?: string;
|
||||
snapshotId?: string;
|
||||
},
|
||||
options: { organizationId: string; tag?: string; snapshotId?: string },
|
||||
) => {
|
||||
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
||||
const destRepoUrl = buildRepoUrl(destConfig);
|
||||
|
||||
const sourceEnv = await buildEnv(sourceConfig);
|
||||
const destEnv = await buildEnv(destConfig);
|
||||
const sourceEnv = await buildEnv(sourceConfig, options.organizationId);
|
||||
const destEnv = await buildEnv(destConfig, options.organizationId);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...sourceEnv,
|
||||
|
|
@ -933,7 +943,6 @@ export const addCommonArgs = (
|
|||
};
|
||||
|
||||
export const restic = {
|
||||
ensurePassfile,
|
||||
init,
|
||||
backup,
|
||||
restore,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,38 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import { sessionsTable, usersTable, account } from "~/server/db/schema";
|
||||
import { sessionsTable, usersTable, account, organization, member } from "~/server/db/schema";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
export async function createTestSession() {
|
||||
const [existingUser] = await db.select().from(usersTable);
|
||||
|
||||
if (!existingUser) {
|
||||
await db.insert(usersTable).values({
|
||||
username: "testuser",
|
||||
email: "test@test.com",
|
||||
name: "Test User",
|
||||
id: crypto.randomUUID(),
|
||||
});
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(usersTable);
|
||||
const userId = crypto.randomUUID();
|
||||
const user = {
|
||||
username: `testuser-${userId}`,
|
||||
email: `${userId}@test.com`,
|
||||
name: "Test User",
|
||||
id: userId,
|
||||
};
|
||||
await db.insert(usersTable).values(user);
|
||||
|
||||
const token = crypto.randomUUID().replace(/-/g, "");
|
||||
const sessionId = token;
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const orgId = crypto.randomUUID();
|
||||
await db.insert(organization).values({
|
||||
id: orgId,
|
||||
name: `Org ${orgId}`,
|
||||
slug: `test-org-${orgId}`,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(member).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
organizationId: orgId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(sessionsTable).values({
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
|
|
@ -28,19 +40,17 @@ export async function createTestSession() {
|
|||
token: token,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
activeOrganizationId: orgId,
|
||||
});
|
||||
|
||||
// Better Auth signs the token using HMAC-SHA256 with the secret
|
||||
// The secret is "test-secret" because we mocked cryptoUtils.deriveSecret
|
||||
const signature = createHmac("sha256", "test-secret").update(token).digest("base64");
|
||||
|
||||
const signedToken = `${token}.${signature}`;
|
||||
|
||||
await db
|
||||
.insert(account)
|
||||
.values({
|
||||
userId: user.id,
|
||||
accountId: "testuser",
|
||||
accountId: user.username,
|
||||
password: await hashPassword("password123"),
|
||||
id: crypto.randomUUID(),
|
||||
providerId: "credentials",
|
||||
|
|
@ -49,5 +59,5 @@ export async function createTestSession() {
|
|||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
return { token: encodeURIComponent(signedToken), user };
|
||||
return { token: encodeURIComponent(signedToken), user, organizationId: orgId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { backupSchedulesTable, type BackupScheduleInsert } from "~/server/db/schema";
|
||||
import { ensureTestOrganization, TEST_ORG_ID } from "./organization";
|
||||
|
||||
export const createTestBackupSchedule = async (overrides: Partial<BackupScheduleInsert> = {}) => {
|
||||
await ensureTestOrganization();
|
||||
|
||||
const backup: BackupScheduleInsert = {
|
||||
name: faker.system.fileName(),
|
||||
cronExpression: "0 0 * * *",
|
||||
repositoryId: "repo_123",
|
||||
volumeId: 1,
|
||||
shortId: faker.string.uuid(),
|
||||
organizationId: TEST_ORG_ID,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
|
|
|||
35
app/test/helpers/organization.ts
Normal file
35
app/test/helpers/organization.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import { organization, type OrganizationMetadata } from "~/server/db/schema";
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
export const TEST_ORG_ID = "test-org-00000001";
|
||||
|
||||
export const createTestOrganization = async (overrides: Partial<typeof organization.$inferInsert> = {}) => {
|
||||
const metadata: OrganizationMetadata = {
|
||||
resticPassword: "test-encrypted-restic-password",
|
||||
};
|
||||
|
||||
const org: typeof organization.$inferInsert = {
|
||||
id: TEST_ORG_ID,
|
||||
name: "Test Organization",
|
||||
slug: `test-org-${faker.string.alphanumeric(6)}`,
|
||||
createdAt: new Date(),
|
||||
metadata,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const existing = await db.query.organization.findFirst({
|
||||
where: (o, { eq }) => eq(o.id, org.id ?? TEST_ORG_ID),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const data = await db.insert(organization).values(org).returning();
|
||||
return data[0];
|
||||
};
|
||||
|
||||
export const ensureTestOrganization = async () => {
|
||||
return createTestOrganization();
|
||||
};
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { repositoriesTable, type RepositoryInsert } from "~/server/db/schema";
|
||||
import { ensureTestOrganization, TEST_ORG_ID } from "./organization";
|
||||
|
||||
export const createTestRepository = async (overrides: Partial<RepositoryInsert> = {}) => {
|
||||
await ensureTestOrganization();
|
||||
|
||||
const repository: RepositoryInsert = {
|
||||
id: faker.string.alphanumeric(6),
|
||||
name: faker.string.alphanumeric(10),
|
||||
|
|
@ -12,6 +15,7 @@ export const createTestRepository = async (overrides: Partial<RepositoryInsert>
|
|||
backend: "local",
|
||||
},
|
||||
type: "local",
|
||||
organizationId: TEST_ORG_ID,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { volumesTable, type VolumeInsert } from "~/server/db/schema";
|
||||
import { ensureTestOrganization, TEST_ORG_ID } from "./organization";
|
||||
|
||||
export const createTestVolume = async (overrides: Partial<VolumeInsert> = {}) => {
|
||||
await ensureTestOrganization();
|
||||
|
||||
const volume: VolumeInsert = {
|
||||
name: faker.system.fileName(),
|
||||
config: {
|
||||
|
|
@ -13,6 +16,7 @@ export const createTestVolume = async (overrides: Partial<VolumeInsert> = {}) =>
|
|||
autoRemount: true,
|
||||
shortId: faker.string.alphanumeric(6),
|
||||
type: "directory",
|
||||
organizationId: TEST_ORG_ID,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ void mock.module("~/server/utils/crypto", () => ({
|
|||
deriveSecret: async () => "test-secret",
|
||||
sealSecret: async (v: string) => v,
|
||||
resolveSecret: async (v: string) => v,
|
||||
generateResticPassword: () => "test-restic-password",
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ services:
|
|||
- SYS_ADMIN
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||
ports:
|
||||
- "4096:4096"
|
||||
volumes:
|
||||
|
|
@ -21,6 +22,7 @@ services:
|
|||
- ~/.config/rclone:/root/.config/rclone
|
||||
|
||||
zerobyte-prod:
|
||||
# image: ghcr.io/nicotsx/zerobyte:v0.22.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
|
@ -35,6 +37,8 @@ services:
|
|||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
environment:
|
||||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
|
|
@ -49,6 +53,7 @@ services:
|
|||
restart: unless-stopped
|
||||
environment:
|
||||
- DISABLE_RATE_LIMITING=true
|
||||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
cap_add:
|
||||
|
|
|
|||
Loading…
Reference in a new issue