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:
Nico 2026-01-20 22:28:22 +01:00 committed by GitHub
parent 44b1a27dd1
commit 451aed8983
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 16874 additions and 419 deletions

View file

@ -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>

View file

@ -1 +1,2 @@
DATABASE_URL=:memory:
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69

View file

@ -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/`):

View file

@ -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>>,

View file

@ -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,

View file

@ -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

View file

@ -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 },
}),
);

View file

@ -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(),
],
});

View file

@ -1 +1,2 @@
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
export const REGISTRATION_ENABLED_KEY = "registrations_enabled";

View file

@ -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 });
}
};

View file

@ -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}

View file

@ -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>

View file

@ -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}

View file

@ -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" />

View file

@ -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

View file

@ -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}`);
},
});

View file

@ -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">

View file

@ -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>

View file

@ -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."

View file

@ -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 },
});
}

View file

@ -6,6 +6,7 @@ type User = {
username: string;
hasDownloadedResticPassword: boolean;
twoFactorEnabled?: boolean | null;
role?: string | null | undefined;
};
type AppContext = {

View 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;

View file

@ -0,0 +1 @@
UPDATE users_table SET role = 'admin' WHERE role IS NULL OR role = '';

View 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;

View 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;

View 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);

View 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;

View 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`);

View 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`);

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX `volumes_table_name_organization_id_unique` ON `volumes_table` (`name`,`organization_id`);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -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");
}
}
};

View file

@ -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.");
}
};

View file

@ -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",

View file

@ -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"),

View 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);
});
});

View file

@ -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");
}

View 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;

View file

@ -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],
}),
}));

View file

@ -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);
}

View file

@ -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 };
}
}

View file

@ -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) {

View file

@ -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() };

View file

@ -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);
}

View file

@ -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();
});

View file

@ -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(() => {

View file

@ -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(() => {

View file

@ -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);

View file

@ -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))),
),
);
});

View file

@ -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);

View file

@ -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];

View file

@ -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"],
};

View file

@ -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}`);
});
});
}

View file

@ -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) => {

View file

@ -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);

View file

@ -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}:`);

View file

@ -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);
}
},
);

View file

@ -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),
},
},
},
},
});

View file

@ -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,
};

View file

@ -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,

View file

@ -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");

View file

@ -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,
};

View file

@ -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,

View file

@ -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 };
}

View file

@ -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,
};

View 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();
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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",
},
}));

View file

@ -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: