diff --git a/AGENTS.md b/AGENTS.md index a1c72922..290f3239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ ## Project Overview -Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). +Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, B2, Azure, GCS, local, and rclone-based storage). ### Type Checking diff --git a/README.md b/README.md index 514fe9d7..f0105089 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ A repository is where your backups will be securely stored encrypted. Zerobyte s - **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc. - **Google Cloud Storage** - Google's cloud storage service - **Azure Blob Storage** - Microsoft Azure storage +- [**Backblaze B2**](https://www.backblaze.com/cloud-storage) - S3 compatible cloud storage - **rclone remotes** - 40+ cloud storage providers via rclone (see below) Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features. diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index 54b0ffc3..d3e755ec 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -26,6 +26,7 @@ import { gcsRepositoryConfigSchema, localRepositoryConfigSchema, r2RepositoryConfigSchema, + b2RepositoryConfigSchema, rcloneRepositoryConfigSchema, restRepositoryConfigSchema, s3RepositoryConfigSchema, @@ -46,6 +47,7 @@ import { import { useServerFn } from "@tanstack/react-start"; import { getServerConstants } from "~/server/lib/functions/server-constants"; import { useSuspenseQuery } from "@tanstack/react-query"; +import { B2RepositoryForm } from "./repository-forms/b2-repository-form"; const formBaseFields = { name: z.string().min(2).max(32), @@ -56,6 +58,7 @@ export const formSchema = z .discriminatedUnion("backend", [ localRepositoryConfigSchema.extend(formBaseFields), s3RepositoryConfigSchema.extend(formBaseFields), + b2RepositoryConfigSchema.extend(formBaseFields), r2RepositoryConfigSchema.extend(formBaseFields), gcsRepositoryConfigSchema.extend(formBaseFields), azureRepositoryConfigSchema.extend(formBaseFields), @@ -87,6 +90,7 @@ type Props = { const defaultValuesForType = (repoBase: string) => ({ local: { backend: "local" as const, compressionMode: "auto" as const, path: repoBase }, s3: { backend: "s3" as const, compressionMode: "auto" as const }, + b2: { backend: "b2" as const, compressionMode: "auto" as const }, r2: { backend: "r2" as const, compressionMode: "auto" as const }, gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, azure: { backend: "azure" as const, compressionMode: "auto" as const }, @@ -185,6 +189,7 @@ export const CreateRepositoryForm = ({ Local S3 Cloudflare R2 + Backblaze B2 Google Cloud Storage Azure Blob Storage REST Server @@ -313,6 +318,7 @@ export const CreateRepositoryForm = ({ {watchedBackend === "local" && } {watchedBackend === "s3" && } + {watchedBackend === "b2" && } {watchedBackend === "r2" && } {watchedBackend === "gcs" && } {watchedBackend === "azure" && } diff --git a/app/client/modules/repositories/components/repository-forms/b2-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/b2-repository-form.tsx new file mode 100644 index 00000000..ba45e4a6 --- /dev/null +++ b/app/client/modules/repositories/components/repository-forms/b2-repository-form.tsx @@ -0,0 +1,79 @@ +import type { UseFormReturn } from "react-hook-form"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../../components/ui/form"; +import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; +import type { RepositoryFormValues } from "../create-repository-form"; + +type Props = { + form: UseFormReturn; +}; + +export const B2RepositoryForm = ({ form }: Props) => { + return ( + <> + ( + + Bucket + + + + Backblaze bucket name for storing backups. + + + )} + /> + ( + + Bucket + + + + Path inside the bucket to store data + + + )} + /> + ( + + Access Key ID + + + + S3 access key ID for authentication. + + + )} + /> + ( + + Secret Access Key + + + + S3 secret access key for authentication. + + + )} + /> + + ); +}; diff --git a/app/server/modules/repositories/repository-config-secrets.ts b/app/server/modules/repositories/repository-config-secrets.ts index d33c9647..2cfaec9c 100644 --- a/app/server/modules/repositories/repository-config-secrets.ts +++ b/app/server/modules/repositories/repository-config-secrets.ts @@ -18,6 +18,14 @@ export const mapRepositoryConfigSecrets = async ( accessKeyId: await transformSecret(config.accessKeyId), secretAccessKey: await transformSecret(config.secretAccessKey), }; + case "b2": + return { + ...config, + customPassword, + cacert, + accountId: await transformSecret(config.accountId), + accountKey: await transformSecret(config.accountKey), + }; case "gcs": return { ...config, diff --git a/packages/core/src/restic/helpers/build-env.ts b/packages/core/src/restic/helpers/build-env.ts index 4561f74a..a2d9666b 100644 --- a/packages/core/src/restic/helpers/build-env.ts +++ b/packages/core/src/restic/helpers/build-env.ts @@ -44,6 +44,10 @@ export const buildEnv = async ( env.AWS_REGION = "auto"; env.AWS_S3_FORCE_PATH_STYLE = "true"; break; + case "b2": + env.B2_ACCOUNT_ID = await deps.resolveSecret(config.accountId); + env.B2_ACCOUNT_KEY = await deps.resolveSecret(config.accountKey); + break; case "gcs": { const decryptedCredentials = await deps.resolveSecret(config.credentialsJson); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); diff --git a/packages/core/src/restic/helpers/build-repo-url.ts b/packages/core/src/restic/helpers/build-repo-url.ts index ba896040..b985c436 100644 --- a/packages/core/src/restic/helpers/build-repo-url.ts +++ b/packages/core/src/restic/helpers/build-repo-url.ts @@ -15,6 +15,8 @@ export const buildRepoUrl = (config: RepositoryConfig): string => { .replace(/\/$/, ""); return `s3:${endpoint}/${config.bucket}`; } + case "b2": + return `b2:${config.bucket}:${config.path}`; case "gcs": return `gs:${config.bucket}:/`; case "azure": diff --git a/packages/core/src/restic/schemas.ts b/packages/core/src/restic/schemas.ts index c38dfc1f..3c03456d 100644 --- a/packages/core/src/restic/schemas.ts +++ b/packages/core/src/restic/schemas.ts @@ -4,6 +4,7 @@ export const REPOSITORY_BACKENDS = { local: "local", s3: "s3", r2: "r2", + b2: "b2", gcs: "gcs", azure: "azure", rclone: "rclone", @@ -50,6 +51,16 @@ export const s3RepositoryConfigSchema = z }) .extend(baseRepositoryConfigSchema.shape); +export const b2RepositoryConfigSchema = z + .object({ + backend: z.literal("b2"), + bucket: z.string().min(1), + path: z.string().min(1), + accountId: z.string().min(1), + accountKey: z.string().min(1), + }) + .extend(baseRepositoryConfigSchema.shape); + export const r2RepositoryConfigSchema = z .object({ backend: z.literal("r2"), @@ -124,6 +135,7 @@ export const sftpRepositoryConfigSchema = z export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [ s3RepositoryConfigSchema, r2RepositoryConfigSchema, + b2RepositoryConfigSchema, localRepositoryConfigSchema, gcsRepositoryConfigSchema, azureRepositoryConfigSchema,