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