From be4c24ee3e2488b7fe482798a21c02629ffe52ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Thu, 18 Dec 2025 13:06:42 +0100 Subject: [PATCH 01/18] feat: add Tailscale integration example with Docker Compose and environment configuration --- examples/README.md | 5 ++ examples/tailscale-sidecar/.env.example | 16 +++++ examples/tailscale-sidecar/README.md | 64 +++++++++++++++++++ examples/tailscale-sidecar/docker-compose.yml | 49 ++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/tailscale-sidecar/.env.example create mode 100644 examples/tailscale-sidecar/README.md create mode 100644 examples/tailscale-sidecar/docker-compose.yml diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..589e016f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,5 @@ +# Examples + +This folder contains runnable, friendly examples for integrating Zerobyte with common tooling. + +- [Tailscale sidecar](tailscale-sidecar/README.md): run Zerobyte behind a Tailscale container using the sidecar networking pattern. diff --git a/examples/tailscale-sidecar/.env.example b/examples/tailscale-sidecar/.env.example new file mode 100644 index 00000000..dcecd934 --- /dev/null +++ b/examples/tailscale-sidecar/.env.example @@ -0,0 +1,16 @@ +# --- Zerobyte --- +TZ=UTC + +# --- Tailscale --- +# Create an auth key in the Tailscale admin console. +# Recommended: ephemeral + pre-approved (or tagged) key. +TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxxxxxxxx + +# How this node should appear in your tailnet. +TS_HOSTNAME=zerobyte + +# Optional extra args passed to `tailscale up`. +# Examples: +# TS_EXTRA_ARGS=--accept-dns=true +# TS_EXTRA_ARGS=--advertise-tags=tag:backup --accept-routes +TS_EXTRA_ARGS= diff --git a/examples/tailscale-sidecar/README.md b/examples/tailscale-sidecar/README.md new file mode 100644 index 00000000..7b86ecc4 --- /dev/null +++ b/examples/tailscale-sidecar/README.md @@ -0,0 +1,64 @@ +# Zerobyte + Tailscale sidecar (Docker Compose) + +This example runs Zerobyte behind a Tailscale sidecar container so the Zerobyte web UI/API can be reached over your tailnet and Zerobyte can access other devices on your tailnet (based on the ACLs/tags you configure in Tailscale). + +It uses a common “sidecar networking” pattern: +- The `tailscale` container brings up a Tailscale node +- The `zerobyte` container shares the `tailscale` network namespace (`network_mode: service:tailscale`) + +## About Tailscale + +Tailscale is a mesh VPN built on WireGuard. It connects devices and containers into a private network (“tailnet”) without opening inbound ports on your router or exposing services directly to the public internet. + +In this example, Tailscale acts as a secure access layer in front of Zerobyte: +- You reach Zerobyte using the node’s tailnet IP/DNS name. +- Access can be restricted using Tailscale ACLs/tags. + +## Prerequisites + +- Docker + Docker Compose +- A Tailscale account and an auth key + +This compose file runs Tailscale in kernel (non-userspace) mode (`TS_USERSPACE=false`), which requires: +- `/dev/net/tun` available on the host +- the `NET_ADMIN` capability +- the `SYS_MODULE` capability (as configured in the compose file) + +## Setup + +1. Copy the env file and fill in your auth key: + ```bash + cp .env.example .env + ``` + +2. Start the stack: + ```bash + docker compose up -d + ``` + +3. In the Tailscale admin console, confirm the node is present (and approved if your policy requires it). + +## Access + +- Over Tailscale: `http://:4096` or `http://:4096` +- Locally (optional): the example publishes `4096:4096` on the host + +If you want Zerobyte to be reachable only via Tailscale, remove the `ports:` section from the `tailscale` service in [docker-compose.yml](docker-compose.yml). + +## Notes + +- `network_mode: service:tailscale` makes Zerobyte share the Tailscale container’s *entire* network namespace (interfaces + routing table). +- Traffic to tailnet IPs (typically `100.x.y.z`) goes over `tailscale0` and is governed by Tailscale ACLs; traffic to your LAN/Internet may still go over the normal network interface depending on routes and host firewall. +- Zerobyte still needs `SYS_ADMIN` and `/dev/fuse` if you intend to mount NFS/SMB/WebDAV volumes from inside the container. + +The example uses these environment variables (see [.env.example](.env.example)): +- `TS_AUTHKEY` (required) +- `TS_HOSTNAME` (optional) +- `TS_EXTRA_ARGS` (optional; passed to `tailscale up`) +- `TZ` (optional) + +## Troubleshooting + +- If the `tailscale` container can’t start due to missing TUN support, ensure your host has `/dev/net/tun` available and that Docker is allowed to use it. +- If your tailnet uses ACLs/tags, set `TS_EXTRA_ARGS` accordingly (for example `--advertise-tags=tag:backup`). +- If the `tailscale` container fails due to `SYS_MODULE` (common on Docker Desktop / restricted hosts), switch to userspace mode by setting `TS_USERSPACE=true` in the compose file and remove `SYS_MODULE` from `cap_add`. diff --git a/examples/tailscale-sidecar/docker-compose.yml b/examples/tailscale-sidecar/docker-compose.yml new file mode 100644 index 00000000..ac3d8c00 --- /dev/null +++ b/examples/tailscale-sidecar/docker-compose.yml @@ -0,0 +1,49 @@ +services: + tailscale: + image: tailscale/tailscale:stable + container_name: zerobyte-tailscale + hostname: ${TS_HOSTNAME:-zerobyte} + restart: unless-stopped + cap_add: + - NET_ADMIN + # Required for Kernel native Tailscale (not userspace mode) + - SYS_MODULE + devices: + - /dev/net/tun:/dev/net/tun + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + # Use Kernel native Tailscale (not userspace mode) + - TS_USERSPACE=false + # Optional flags passed to `tailscale up`. + # Examples: + # - --advertise-tags=tag:zerobyte + # - --accept-dns=true --accept-routes + - TS_EXTRA_ARGS=${TS_EXTRA_ARGS:-} + volumes: + - tailscale-state:/var/lib/tailscale + # If you only want access over Tailscale (not from the local network), remove this. + ports: + - "4096:4096" + + zerobyte: + image: ghcr.io/nicotsx/zerobyte:latest + container_name: zerobyte + restart: unless-stopped + # Share the Tailscale network namespace (sidecar pattern) + network_mode: service:tailscale + depends_on: + - tailscale + devices: + - /dev/fuse:/dev/fuse + cap_add: + - SYS_ADMIN + environment: + - TZ=${TZ:-UTC} + volumes: + - /etc/localtime:/etc/localtime:ro + - zerobyte-data:/var/lib/zerobyte + +volumes: + tailscale-state: + zerobyte-data: From f995a2ca8d444e28cb97f5af032960148c8682df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Fri, 19 Dec 2025 13:28:05 +0100 Subject: [PATCH 02/18] Add examples directory and runnable setups --- README.md | 93 ++----------------- docker-compose.yml | 12 --- examples/README.md | 22 ++++- examples/basic-docker-compose/.env.example | 2 + examples/basic-docker-compose/README.md | 25 +++++ .../basic-docker-compose/docker-compose.yml | 16 ++++ examples/directory-bind-mount/.env.example | 8 ++ examples/directory-bind-mount/README.md | 34 +++++++ .../directory-bind-mount/docker-compose.yml | 13 +++ examples/rclone-config-mount/.env.example | 8 ++ examples/rclone-config-mount/README.md | 41 ++++++++ .../rclone-config-mount/docker-compose.yml | 13 +++ examples/secrets-placeholders/.env.example | 6 ++ examples/secrets-placeholders/README.md | 59 ++++++++++++ .../secrets-placeholders/docker-compose.yml | 26 ++++++ .../simplified-docker-compose/.env.example | 2 + examples/simplified-docker-compose/README.md | 25 +++++ .../docker-compose.yml | 12 +++ examples/tailscale-sidecar/.env.example | 6 ++ examples/tailscale-sidecar/README.md | 43 +++++++-- examples/tailscale-sidecar/docker-compose.yml | 23 ++--- 21 files changed, 371 insertions(+), 118 deletions(-) create mode 100644 examples/basic-docker-compose/.env.example create mode 100644 examples/basic-docker-compose/README.md create mode 100644 examples/basic-docker-compose/docker-compose.yml create mode 100644 examples/directory-bind-mount/.env.example create mode 100644 examples/directory-bind-mount/README.md create mode 100644 examples/directory-bind-mount/docker-compose.yml create mode 100644 examples/rclone-config-mount/.env.example create mode 100644 examples/rclone-config-mount/README.md create mode 100644 examples/rclone-config-mount/docker-compose.yml create mode 100644 examples/secrets-placeholders/.env.example create mode 100644 examples/secrets-placeholders/README.md create mode 100644 examples/secrets-placeholders/docker-compose.yml create mode 100644 examples/simplified-docker-compose/.env.example create mode 100644 examples/simplified-docker-compose/README.md create mode 100644 examples/simplified-docker-compose/docker-compose.yml diff --git a/README.md b/README.md index f05ece02..1e7da50a 100644 --- a/README.md +++ b/README.md @@ -67,33 +67,9 @@ docker compose up -d Once the container is running, you can access the web interface at `http://:4096`. -### Simplified setup (No remote mounts) +## Examples -If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`: - -```yaml -services: - zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.19 - container_name: zerobyte - restart: unless-stopped - ports: - - "4096:4096" - environment: - - TZ=Europe/Paris # Set your timezone here - volumes: - - /etc/localtime:/etc/localtime:ro - - /var/lib/zerobyte:/var/lib/zerobyte - - /path/to/your/directory:/mydata -``` - -**Trade-offs:** -- ✅ Improved security by reducing container capabilities -- ✅ Support for local directories -- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone) -- ❌ Cannot mount NFS, SMB, or WebDAV shares directly from Zerobyte - -If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`. +See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples. ## Adding your first volume @@ -101,36 +77,7 @@ Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings. -If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section: - -```diff -services: - zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.19 - container_name: zerobyte - restart: unless-stopped - cap_add: - - SYS_ADMIN - ports: - - "4096:4096" - devices: - - /dev/fuse:/dev/fuse - environment: - - TZ=Europe/Paris - volumes: - - /etc/localtime:/etc/localtime:ro - - /var/lib/zerobyte:/var/lib/zerobyte -+ - /path/to/your/directory:/mydata -``` - -After updating the `docker-compose.yml` file, restart the Zerobyte container to apply the changes: - -```bash -docker compose down -docker compose up -d -``` - -Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path. +If you want to back up a host directory, see the bind-mount example: [examples/directory-bind-mount/README.md](examples/directory-bind-mount/README.md). ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true) @@ -155,49 +102,28 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov **Setup instructions:** 1. **Install rclone on your host system** (if not already installed): + ```bash curl https://rclone.org/install.sh | sudo bash ``` 2. **Configure your cloud storage remote** using rclone's interactive config: + ```bash rclone config ``` + Follow the prompts to set up your cloud storage provider. For OAuth providers (Google Drive, Dropbox, etc.), rclone will guide you through the authentication flow. 3. **Verify your remote is configured**: + ```bash rclone listremotes ``` -4. **Mount the rclone config into the Zerobyte container** by updating your `docker-compose.yml`: - ```diff - services: - zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.19 - container_name: zerobyte - restart: unless-stopped - cap_add: - - SYS_ADMIN - ports: - - "4096:4096" - devices: - - /dev/fuse:/dev/fuse - environment: - - TZ=Europe/Paris - volumes: - - /etc/localtime:/etc/localtime:ro - - /var/lib/zerobyte:/var/lib/zerobyte - + - ~/.config/rclone:/root/.config/rclone - ``` +4. **Mount the rclone config into the Zerobyte container** using the example: [examples/rclone-config-mount/README.md](examples/rclone-config-mount/README.md) -5. **Restart the Zerobyte container**: - ```bash - docker compose down - docker compose up -d - ``` - -6. **Create a repository** in Zerobyte: +5. **Create a repository** in Zerobyte: - Select "rclone" as the repository type - Choose your configured remote from the dropdown - Specify the path within your remote (e.g., `backups/zerobyte`) @@ -209,6 +135,7 @@ For a complete list of supported providers, see the [rclone documentation](https Once you have added a volume and created a repository, you can create your first backup job. A backup job defines the schedule and parameters for backing up a specific volume to a designated repository. When creating a backup job, you can specify the following settings: + - **Schedule**: Define how often the backup should run (e.g., daily, weekly) - **Retention Policy**: Set rules for how long backups should be retained (e.g., keep daily backups for 7 days, weekly backups for 4 weeks) - **Paths**: Specify which files or directories to include in the backup diff --git a/docker-compose.yml b/docker-compose.yml index 5ac4eee7..94b376c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,19 +12,13 @@ services: - SYS_ADMIN environment: - NODE_ENV=development - # - SMB_PASSWORD=secret ports: - "4096:4096" - # secrets: - # - smb-password volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte - - ./app:/app/app - ~/.config/rclone:/root/.config/rclone - # - /run/docker/plugins:/run/docker/plugins - # - /var/run/docker.sock:/var/run/docker.sock zerobyte-prod: build: @@ -42,10 +36,4 @@ services: volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte:rshared - - /run/docker/plugins:/run/docker/plugins - - /var/run/docker.sock:/var/run/docker.sock - ~/.config/rclone:/root/.config/rclone - -# secrets: -# smb-password: -# file: ./smb-password.txt diff --git a/examples/README.md b/examples/README.md index 589e016f..08e6af24 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,23 @@ # Examples -This folder contains runnable, friendly examples for integrating Zerobyte with common tooling. +This folder contains runnable, copy/paste-friendly examples for running Zerobyte in different setups. -- [Tailscale sidecar](tailscale-sidecar/README.md): run Zerobyte behind a Tailscale container using the sidecar networking pattern. +## Table of contents + +### Basic usage + +- [Basic Docker Compose](basic-docker-compose/README.md) — standard deployment with remote mount support (includes `SYS_ADMIN` + `/dev/fuse`). +- [Simplified Docker Compose (no remote mounts)](simplified-docker-compose/README.md) — reduced-privilege deployment (no `SYS_ADMIN`, no `/dev/fuse`). +- [Bind-mount a local directory](directory-bind-mount/README.md) — back up a host folder by mounting it into the container. +- [Mount an rclone config](rclone-config-mount/README.md) — use rclone-based repository backends by mounting your rclone config. +- [Secret placeholders + Docker secrets](secrets-placeholders/README.md) — keep secrets out of the DB using `env://...` and `file://...` references. + +### Advanced setups + +- [Tailscale sidecar](tailscale-sidecar/README.md) — run Zerobyte behind a Tailscale sidecar using shared networking. + +## Conventions + +- Each example should be runnable on its own (ideally via Docker Compose). +- Each example should include a README with prerequisites, troubleshooting, etc. +- If configuration is needed, include a `.env.example`. diff --git a/examples/basic-docker-compose/.env.example b/examples/basic-docker-compose/.env.example new file mode 100644 index 00000000..50409532 --- /dev/null +++ b/examples/basic-docker-compose/.env.example @@ -0,0 +1,2 @@ +# Timezone used by the container +TZ=UTC diff --git a/examples/basic-docker-compose/README.md b/examples/basic-docker-compose/README.md new file mode 100644 index 00000000..4b326066 --- /dev/null +++ b/examples/basic-docker-compose/README.md @@ -0,0 +1,25 @@ +# Basic Docker Compose + +Minimal "standard" deployment for Zerobyte. + +This setup enables remote mount backends (NFS/SMB/WebDAV) from inside the container by granting the required capability and FUSE device. + +## Prerequisites + +- Docker + Docker Compose + +## Setup + +```bash +cp .env.example .env +docker compose up -d +``` + +## Access + +- UI/API: `http://:4096` + +## Notes + +- This example uses `cap_add: SYS_ADMIN` and `/dev/fuse` to support mounting remote volumes. +- Do not place `/var/lib/zerobyte` on a network share. diff --git a/examples/basic-docker-compose/docker-compose.yml b/examples/basic-docker-compose/docker-compose.yml new file mode 100644 index 00000000..0f34772a --- /dev/null +++ b/examples/basic-docker-compose/docker-compose.yml @@ -0,0 +1,16 @@ +services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:latest + container_name: zerobyte + restart: unless-stopped + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse:/dev/fuse + ports: + - "4096:4096" + environment: + - TZ=${TZ:-UTC} + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte diff --git a/examples/directory-bind-mount/.env.example b/examples/directory-bind-mount/.env.example new file mode 100644 index 00000000..03946fe6 --- /dev/null +++ b/examples/directory-bind-mount/.env.example @@ -0,0 +1,8 @@ +# Timezone used by the container +TZ=UTC + +# Absolute path on the Docker host to back up. +# Examples: +# - /srv/data +# - /mnt/storage/photos +HOST_DATA_DIR=/path/to/your/directory diff --git a/examples/directory-bind-mount/README.md b/examples/directory-bind-mount/README.md new file mode 100644 index 00000000..25bfac9d --- /dev/null +++ b/examples/directory-bind-mount/README.md @@ -0,0 +1,34 @@ +# Bind-mount a local directory + +This example shows how to back up a host directory by bind-mounting it into the Zerobyte container. + +It uses the simplified setup (no remote mounts). + +## Prerequisites + +- Docker + Docker Compose + +## Setup + +1. Copy the env file: + +```bash +cp .env.example .env +``` + +1. Edit `.env` and set `HOST_DATA_DIR` to the directory you want to back up. + +1. Start the stack: + +```bash +docker compose up -d +``` + +## Use in Zerobyte + +- Create a new volume of type **Directory** +- Select the mounted path shown in the compose file: `/mydata` + +## Access + +- UI/API: `http://:4096` diff --git a/examples/directory-bind-mount/docker-compose.yml b/examples/directory-bind-mount/docker-compose.yml new file mode 100644 index 00000000..bcb8f1ca --- /dev/null +++ b/examples/directory-bind-mount/docker-compose.yml @@ -0,0 +1,13 @@ +services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:latest + container_name: zerobyte + restart: unless-stopped + ports: + - "4096:4096" + environment: + - TZ=${TZ:-UTC} + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte + - ${HOST_DATA_DIR}:/mydata diff --git a/examples/rclone-config-mount/.env.example b/examples/rclone-config-mount/.env.example new file mode 100644 index 00000000..15bb93fa --- /dev/null +++ b/examples/rclone-config-mount/.env.example @@ -0,0 +1,8 @@ +# Timezone used by the container +TZ=UTC + +# Absolute path on the Docker host to your rclone config directory. +# Common locations: +# - Linux: /home//.config/rclone +# - Linux (root): /root/.config/rclone +RCLONE_CONFIG_DIR=/path/to/rclone/config diff --git a/examples/rclone-config-mount/README.md b/examples/rclone-config-mount/README.md new file mode 100644 index 00000000..ebfdb2bc --- /dev/null +++ b/examples/rclone-config-mount/README.md @@ -0,0 +1,41 @@ +# Mount an rclone config (for rclone repositories) + +This example shows how to make an existing rclone configuration available inside the Zerobyte container. + +Use this if you want to use **rclone** as a repository backend (Dropbox/Google Drive/OneDrive/etc.). + +## Prerequisites + +- Docker + Docker Compose +- An existing rclone config directory on the Docker host + +If you don't have one yet: + +```bash +rclone config +``` + +## Setup + +1. Copy the env file: + +```bash +cp .env.example .env +``` + +1. Edit `.env` and set `RCLONE_CONFIG_DIR` to the absolute path of your host rclone config directory. + +1. Start the stack: + +```bash +docker compose up -d +``` + +## Access + +- UI/API: `http://:4096` + +## Notes + +- This setup does not require `SYS_ADMIN` or `/dev/fuse`. +- The rclone config is mounted read-only into `/root/.config/rclone`. diff --git a/examples/rclone-config-mount/docker-compose.yml b/examples/rclone-config-mount/docker-compose.yml new file mode 100644 index 00000000..b1f167c2 --- /dev/null +++ b/examples/rclone-config-mount/docker-compose.yml @@ -0,0 +1,13 @@ +services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:latest + container_name: zerobyte + restart: unless-stopped + ports: + - "4096:4096" + environment: + - TZ=${TZ:-UTC} + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte + - ${RCLONE_CONFIG_DIR}:/root/.config/rclone:ro diff --git a/examples/secrets-placeholders/.env.example b/examples/secrets-placeholders/.env.example new file mode 100644 index 00000000..e1ed0423 --- /dev/null +++ b/examples/secrets-placeholders/.env.example @@ -0,0 +1,6 @@ +# Timezone used by the container +TZ=UTC + +# Optional example secret injected via environment variable. +# Use it in Zerobyte config fields as: env://ZEROBYTE_SMB_PASSWORD +ZEROBYTE_SMB_PASSWORD= diff --git a/examples/secrets-placeholders/README.md b/examples/secrets-placeholders/README.md new file mode 100644 index 00000000..93441708 --- /dev/null +++ b/examples/secrets-placeholders/README.md @@ -0,0 +1,59 @@ +# Secret placeholders (env:// and file://) + Docker secrets + +Zerobyte supports **secret placeholders** in many configuration fields (repositories, volumes, notifications). +Instead of storing raw secrets in the database, you can store a reference that gets resolved at runtime. + +Supported formats: + +- `env://VAR_NAME` → reads `process.env.VAR_NAME` +- `file://name` → reads `/run/secrets/name` (Docker Compose / Docker secrets) + +This example shows how to run Zerobyte with: + +- an environment variable you can reference via `env://...` +- a Docker secret you can reference via `file://...` + +## Prerequisites + +- Docker + Docker Compose + +This example includes `SYS_ADMIN` and `/dev/fuse` because it’s intended for SMB volumes (remote mounts). + +## Setup + +1. Copy the env file: + +```bash +cp .env.example .env +``` + +1. Create a Docker secret file (this file is not meant to be committed): + +```bash +printf "your-smb-password" > smb-password.txt +``` + +1. Start Zerobyte: + +```bash +docker compose up -d +``` + +## Using placeholders in Zerobyte + +You can now use the placeholders for example in these Zerobyte configuration fields: + +| UI section | Type | Field | Example value | +| --- | --- | --- | --- | +| Volumes → Create volume | SMB | Password | `file://smb_password` or `env://ZEROBYTE_SMB_PASSWORD` | +| Volumes → Create volume | WebDAV | Password | `file://webdav_password` or `env://ZEROBYTE_WEBDAV_PASSWORD` | +| Repositories → Create repository | S3 | Secret access key | `file://aws_secret_access_key` or `env://AWS_SECRET_ACCESS_KEY` | +| Repositories → Create repository | SFTP | SSH Private key | `file://sftp_private_key` or `env://SFTP_PRIVATE_KEY` | +| Notifications → Create notification | Telegram | Bot token | `file://telegram_bot_token` or `env://TELEGRAM_BOT_TOKEN` | + +Notes: + +- Placeholder names used in these examples are arbitrary; you can choose any valid name. +- Placeholder names are case-sensitive. +- With `file://...`, the secret name must be a single path segment (no `/` or `\\`). +- You can still paste a raw secret, but placeholders can be considered safer and easier to rotate. diff --git a/examples/secrets-placeholders/docker-compose.yml b/examples/secrets-placeholders/docker-compose.yml new file mode 100644 index 00000000..96f3d86d --- /dev/null +++ b/examples/secrets-placeholders/docker-compose.yml @@ -0,0 +1,26 @@ +services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:latest + container_name: zerobyte + restart: unless-stopped + # Required for mounting remote volumes (SMB/NFS/WebDAV) from inside the container + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse:/dev/fuse + ports: + - "4096:4096" + environment: + - TZ=${TZ:-UTC} + # Example env-backed secret (refer to it in Zerobyte as env://ZEROBYTE_SMB_PASSWORD) + - ZEROBYTE_SMB_PASSWORD=${ZEROBYTE_SMB_PASSWORD:-} + secrets: + # Example file-backed secret (refer to it in Zerobyte as file://smb_password) + - smb_password + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte + +secrets: + smb_password: + file: ./smb-password.txt diff --git a/examples/simplified-docker-compose/.env.example b/examples/simplified-docker-compose/.env.example new file mode 100644 index 00000000..50409532 --- /dev/null +++ b/examples/simplified-docker-compose/.env.example @@ -0,0 +1,2 @@ +# Timezone used by the container +TZ=UTC diff --git a/examples/simplified-docker-compose/README.md b/examples/simplified-docker-compose/README.md new file mode 100644 index 00000000..0388af4c --- /dev/null +++ b/examples/simplified-docker-compose/README.md @@ -0,0 +1,25 @@ +# Simplified Docker Compose (no remote mounts) + +A reduced-privilege setup for Zerobyte when you do **not** need to mount NFS/SMB/WebDAV from inside the container. + +## Prerequisites + +- Docker + Docker Compose + +## Setup + +```bash +cp .env.example .env +docker compose up -d +``` + +## Access + +- UI/API: `http://:4096` + +## Trade-offs + +- ✅ No `SYS_ADMIN` +- ✅ No `/dev/fuse` +- ✅ Still supports all repository backends (local, S3, GCS, Azure, rclone) +- ❌ Cannot mount remote shares from inside Zerobyte diff --git a/examples/simplified-docker-compose/docker-compose.yml b/examples/simplified-docker-compose/docker-compose.yml new file mode 100644 index 00000000..69df9689 --- /dev/null +++ b/examples/simplified-docker-compose/docker-compose.yml @@ -0,0 +1,12 @@ +services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:latest + container_name: zerobyte + restart: unless-stopped + ports: + - "4096:4096" + environment: + - TZ=${TZ:-UTC} + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte diff --git a/examples/tailscale-sidecar/.env.example b/examples/tailscale-sidecar/.env.example index dcecd934..5d831079 100644 --- a/examples/tailscale-sidecar/.env.example +++ b/examples/tailscale-sidecar/.env.example @@ -9,6 +9,12 @@ TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxxxxxxxx # How this node should appear in your tailnet. TS_HOSTNAME=zerobyte +# Kernel-mode (false) vs userspace-mode (true). +# - false: requires /dev/net/tun on the host +# - true: works on Docker Desktop / hosts without TUN, but you must also remove +# the /dev/net/tun device mapping from docker-compose.yml +TS_USERSPACE=false + # Optional extra args passed to `tailscale up`. # Examples: # TS_EXTRA_ARGS=--accept-dns=true diff --git a/examples/tailscale-sidecar/README.md b/examples/tailscale-sidecar/README.md index 7b86ecc4..b8c022a8 100644 --- a/examples/tailscale-sidecar/README.md +++ b/examples/tailscale-sidecar/README.md @@ -3,6 +3,7 @@ This example runs Zerobyte behind a Tailscale sidecar container so the Zerobyte web UI/API can be reached over your tailnet and Zerobyte can access other devices on your tailnet (based on the ACLs/tags you configure in Tailscale). It uses a common “sidecar networking” pattern: + - The `tailscale` container brings up a Tailscale node - The `zerobyte` container shares the `tailscale` network namespace (`network_mode: service:tailscale`) @@ -11,6 +12,7 @@ It uses a common “sidecar networking” pattern: Tailscale is a mesh VPN built on WireGuard. It connects devices and containers into a private network (“tailnet”) without opening inbound ports on your router or exposing services directly to the public internet. In this example, Tailscale acts as a secure access layer in front of Zerobyte: + - You reach Zerobyte using the node’s tailnet IP/DNS name. - Access can be restricted using Tailscale ACLs/tags. @@ -19,19 +21,28 @@ In this example, Tailscale acts as a secure access layer in front of Zerobyte: - Docker + Docker Compose - A Tailscale account and an auth key -This compose file runs Tailscale in kernel (non-userspace) mode (`TS_USERSPACE=false`), which requires: -- `/dev/net/tun` available on the host -- the `NET_ADMIN` capability -- the `SYS_MODULE` capability (as configured in the compose file) +This example supports two Tailscale modes: + +- **Kernel mode** (`TS_USERSPACE=false`, default): + - requires `/dev/net/tun` on the host + - requires `NET_ADMIN` + - may require `SYS_MODULE` on some hosts (kept commented out in the compose file) + +- **Userspace mode** (`TS_USERSPACE=true`): + - does **not** require `/dev/net/tun` + - works better on Docker Desktop / restricted hosts + - requires a small edit in the compose file (see Troubleshooting) ## Setup 1. Copy the env file and fill in your auth key: + ```bash cp .env.example .env ``` 2. Start the stack: + ```bash docker compose up -d ``` @@ -40,25 +51,43 @@ This compose file runs Tailscale in kernel (non-userspace) mode (`TS_USERSPACE=f ## Access -- Over Tailscale: `http://:4096` or `http://:4096` +- Over Tailscale: `http://:4096` or `http://:4096` (if MagicDNS is enabled) - Locally (optional): the example publishes `4096:4096` on the host -If you want Zerobyte to be reachable only via Tailscale, remove the `ports:` section from the `tailscale` service in [docker-compose.yml](docker-compose.yml). +If you want Zerobyte to be reachable only via Tailscale, remove the `ports:` section from the `tailscale` service in [docker-compose.yml](docker-compose.yml). Zerobyte will still be able to access the internet and other resources outside the tailnet, but UI will only be accessible over Tailscale with possibility to further restrict access to it with ACLs/tags. ## Notes - `network_mode: service:tailscale` makes Zerobyte share the Tailscale container’s *entire* network namespace (interfaces + routing table). - Traffic to tailnet IPs (typically `100.x.y.z`) goes over `tailscale0` and is governed by Tailscale ACLs; traffic to your LAN/Internet may still go over the normal network interface depending on routes and host firewall. +- If `--accept-routes` is used, the Tailscale container may add routes to your routing table that Zerobyte will also use and be able to access remote networks. +- If `--accept-dns` is used, Zerobyte will also use Tailscale’s DNS servers. - Zerobyte still needs `SYS_ADMIN` and `/dev/fuse` if you intend to mount NFS/SMB/WebDAV volumes from inside the container. The example uses these environment variables (see [.env.example](.env.example)): + - `TS_AUTHKEY` (required) - `TS_HOSTNAME` (optional) - `TS_EXTRA_ARGS` (optional; passed to `tailscale up`) +- `TS_USERSPACE` (optional; set to `true` to use userspace mode) - `TZ` (optional) ## Troubleshooting - If the `tailscale` container can’t start due to missing TUN support, ensure your host has `/dev/net/tun` available and that Docker is allowed to use it. - If your tailnet uses ACLs/tags, set `TS_EXTRA_ARGS` accordingly (for example `--advertise-tags=tag:backup`). -- If the `tailscale` container fails due to `SYS_MODULE` (common on Docker Desktop / restricted hosts), switch to userspace mode by setting `TS_USERSPACE=true` in the compose file and remove `SYS_MODULE` from `cap_add`. +- If the `tailscale` container fails due to Docker Desktop / missing TUN support: set `TS_USERSPACE=true` in your `.env`, remove the `/dev/net/tun:/dev/net/tun` device mapping in [docker-compose.yml](docker-compose.yml), and keep `SYS_MODULE` disabled (commented out). + +To confirm the tailnet address of the container: + +```bash +docker exec zerobyte-tailscale tailscale status +docker exec zerobyte-tailscale tailscale ip -4 +``` + +To confirm received routes when `--accept-routes` is used in kernel mode: +(Missing routers could be due to ACLs or because `--accept-routes` is not set or not supported in userspace mode) +```bash +docker exec zerobyte-tailscale ip route +docker exec zerobyte-tailscale ip route show table 52 +``` \ No newline at end of file diff --git a/examples/tailscale-sidecar/docker-compose.yml b/examples/tailscale-sidecar/docker-compose.yml index ac3d8c00..4f1d4396 100644 --- a/examples/tailscale-sidecar/docker-compose.yml +++ b/examples/tailscale-sidecar/docker-compose.yml @@ -6,22 +6,25 @@ services: restart: unless-stopped cap_add: - NET_ADMIN - # Required for Kernel native Tailscale (not userspace mode) - - SYS_MODULE + # Optional: Some hosts require this for kernel-mode Tailscale. + # If it causes issues (common on Docker Desktop), keep it disabled. + # - SYS_MODULE + # Kernel-mode Tailscale needs /dev/net/tun. If you enable userspace mode + # (TS_USERSPACE=true), remove this device mapping. devices: - /dev/net/tun:/dev/net/tun environment: - TS_AUTHKEY=${TS_AUTHKEY} - TS_STATE_DIR=/var/lib/tailscale - # Use Kernel native Tailscale (not userspace mode) - - TS_USERSPACE=false + # Kernel-mode (false) vs userspace-mode (true). + - TS_USERSPACE=${TS_USERSPACE:-false} # Optional flags passed to `tailscale up`. # Examples: # - --advertise-tags=tag:zerobyte # - --accept-dns=true --accept-routes - TS_EXTRA_ARGS=${TS_EXTRA_ARGS:-} volumes: - - tailscale-state:/var/lib/tailscale + - /var/lib/tailscale:/var/lib/tailscale # If you only want access over Tailscale (not from the local network), remove this. ports: - "4096:4096" @@ -34,16 +37,8 @@ services: network_mode: service:tailscale depends_on: - tailscale - devices: - - /dev/fuse:/dev/fuse - cap_add: - - SYS_ADMIN environment: - TZ=${TZ:-UTC} volumes: - /etc/localtime:/etc/localtime:ro - - zerobyte-data:/var/lib/zerobyte - -volumes: - tailscale-state: - zerobyte-data: + - /var/lib/zerobyte:/var/lib/zerobyte From 9d46bbbcb183c3fb83156d7229733bdadef84c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Fri, 19 Dec 2025 14:26:10 +0100 Subject: [PATCH 03/18] Update examples/tailscale-sidecar/docker-compose.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/tailscale-sidecar/docker-compose.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/tailscale-sidecar/docker-compose.yml b/examples/tailscale-sidecar/docker-compose.yml index 4f1d4396..107159fc 100644 --- a/examples/tailscale-sidecar/docker-compose.yml +++ b/examples/tailscale-sidecar/docker-compose.yml @@ -9,8 +9,10 @@ services: # Optional: Some hosts require this for kernel-mode Tailscale. # If it causes issues (common on Docker Desktop), keep it disabled. # - SYS_MODULE - # Kernel-mode Tailscale needs /dev/net/tun. If you enable userspace mode - # (TS_USERSPACE=true), remove this device mapping. + # Kernel-mode Tailscale (TS_USERSPACE=false) requires /dev/net/tun. + # If you switch to userspace mode (TS_USERSPACE=true), you MUST remove this + # devices section (or at least this mapping), otherwise the container may + # fail to start or hit runtime errors on some hosts. devices: - /dev/net/tun:/dev/net/tun environment: From 89fbeb7fe3a156f75efbe918af1436dbbf33179f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Fri, 19 Dec 2025 14:26:49 +0100 Subject: [PATCH 04/18] Update examples/tailscale-sidecar/docker-compose.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/tailscale-sidecar/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tailscale-sidecar/docker-compose.yml b/examples/tailscale-sidecar/docker-compose.yml index 107159fc..eccf001b 100644 --- a/examples/tailscale-sidecar/docker-compose.yml +++ b/examples/tailscale-sidecar/docker-compose.yml @@ -7,7 +7,7 @@ services: cap_add: - NET_ADMIN # Optional: Some hosts require this for kernel-mode Tailscale. - # If it causes issues (common on Docker Desktop), keep it disabled. + # If it causes issues (common on Docker Desktop), keep it commented out (disabled as shown here). # - SYS_MODULE # Kernel-mode Tailscale (TS_USERSPACE=false) requires /dev/net/tun. # If you switch to userspace mode (TS_USERSPACE=true), you MUST remove this From 7cdf1d72e979bd079deb466a3fefd35e1e1a328f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Fri, 19 Dec 2025 14:27:09 +0100 Subject: [PATCH 05/18] Update examples/tailscale-sidecar/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/tailscale-sidecar/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tailscale-sidecar/README.md b/examples/tailscale-sidecar/README.md index b8c022a8..2d128cb4 100644 --- a/examples/tailscale-sidecar/README.md +++ b/examples/tailscale-sidecar/README.md @@ -86,7 +86,7 @@ docker exec zerobyte-tailscale tailscale ip -4 ``` To confirm received routes when `--accept-routes` is used in kernel mode: -(Missing routers could be due to ACLs or because `--accept-routes` is not set or not supported in userspace mode) +(Missing routes could be due to ACLs or because `--accept-routes` is not set or not supported in userspace mode) ```bash docker exec zerobyte-tailscale ip route docker exec zerobyte-tailscale ip route show table 52 From 4e2f8f0160f3bf248088e78b0fa611d0f67ac87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Fri, 19 Dec 2025 14:39:57 +0100 Subject: [PATCH 06/18] implement copilot and rabbitai suggestions --- examples/directory-bind-mount/README.md | 4 ++-- examples/directory-bind-mount/docker-compose.yml | 2 +- examples/rclone-config-mount/README.md | 4 ++-- examples/secrets-placeholders/.gitignore | 5 +++++ examples/secrets-placeholders/README.md | 6 ++++-- examples/tailscale-sidecar/docker-compose.yml | 5 +++++ 6 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 examples/secrets-placeholders/.gitignore diff --git a/examples/directory-bind-mount/README.md b/examples/directory-bind-mount/README.md index 25bfac9d..51d500f7 100644 --- a/examples/directory-bind-mount/README.md +++ b/examples/directory-bind-mount/README.md @@ -16,9 +16,9 @@ It uses the simplified setup (no remote mounts). cp .env.example .env ``` -1. Edit `.env` and set `HOST_DATA_DIR` to the directory you want to back up. +2. Edit `.env` and set `HOST_DATA_DIR` to the directory you want to back up. -1. Start the stack: +3. Start the stack: ```bash docker compose up -d diff --git a/examples/directory-bind-mount/docker-compose.yml b/examples/directory-bind-mount/docker-compose.yml index bcb8f1ca..e050d4c1 100644 --- a/examples/directory-bind-mount/docker-compose.yml +++ b/examples/directory-bind-mount/docker-compose.yml @@ -10,4 +10,4 @@ services: volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte - - ${HOST_DATA_DIR}:/mydata + - ${HOST_DATA_DIR:?HOST_DATA_DIR must be set}:/mydata diff --git a/examples/rclone-config-mount/README.md b/examples/rclone-config-mount/README.md index ebfdb2bc..e1c86790 100644 --- a/examples/rclone-config-mount/README.md +++ b/examples/rclone-config-mount/README.md @@ -23,9 +23,9 @@ rclone config cp .env.example .env ``` -1. Edit `.env` and set `RCLONE_CONFIG_DIR` to the absolute path of your host rclone config directory. +2. Edit `.env` and set `RCLONE_CONFIG_DIR` to the absolute path of your host rclone config directory. -1. Start the stack: +3. Start the stack: ```bash docker compose up -d diff --git a/examples/secrets-placeholders/.gitignore b/examples/secrets-placeholders/.gitignore new file mode 100644 index 00000000..55d6353f --- /dev/null +++ b/examples/secrets-placeholders/.gitignore @@ -0,0 +1,5 @@ +# Secret files - do not commit +smb-password.txt +*-password.txt +*.secret +*.key diff --git a/examples/secrets-placeholders/README.md b/examples/secrets-placeholders/README.md index 93441708..dac55772 100644 --- a/examples/secrets-placeholders/README.md +++ b/examples/secrets-placeholders/README.md @@ -27,13 +27,15 @@ This example includes `SYS_ADMIN` and `/dev/fuse` because it’s intended for SM cp .env.example .env ``` -1. Create a Docker secret file (this file is not meant to be committed): +2. Create a Docker secret file. + + ⚠️ **Important**: never commit real credentials. This folder includes a `.gitignore` to help prevent accidentally committing secret files. ```bash printf "your-smb-password" > smb-password.txt ``` -1. Start Zerobyte: +3. Start Zerobyte: ```bash docker compose up -d diff --git a/examples/tailscale-sidecar/docker-compose.yml b/examples/tailscale-sidecar/docker-compose.yml index eccf001b..40f4cfd1 100644 --- a/examples/tailscale-sidecar/docker-compose.yml +++ b/examples/tailscale-sidecar/docker-compose.yml @@ -35,6 +35,11 @@ services: image: ghcr.io/nicotsx/zerobyte:latest container_name: zerobyte restart: unless-stopped + # Uncomment if you need to mount NFS/SMB/WebDAV volumes: + # cap_add: + # - SYS_ADMIN + # devices: + # - /dev/fuse:/dev/fuse # Share the Tailscale network namespace (sidecar pattern) network_mode: service:tailscale depends_on: From 11b588ce207357bc717fe96ca1868a11f547dfb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Fri, 19 Dec 2025 23:38:39 +0000 Subject: [PATCH 07/18] readded some examples in main readme --- README.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1e7da50a..e5aaf7ca 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,34 @@ docker compose up -d Once the container is running, you can access the web interface at `http://:4096`. +### Simplified setup (No remote mounts) + +If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`: + +```yaml +services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:v0.19 + container_name: zerobyte + restart: unless-stopped + ports: + - "4096:4096" + environment: + - TZ=Europe/Paris # Set your timezone here + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte + - /path/to/your/directory:/mydata +``` + +**Trade-offs:** +- ✅ Improved security by reducing container capabilities +- ✅ Support for local directories +- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone) +- ❌ Cannot mount NFS, SMB, or WebDAV shares directly from Zerobyte + +If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`. + ## Examples See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples. @@ -77,7 +105,36 @@ Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings. -If you want to back up a host directory, see the bind-mount example: [examples/directory-bind-mount/README.md](examples/directory-bind-mount/README.md). +If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section: + +```diff +services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:v0.19 + container_name: zerobyte + restart: unless-stopped + cap_add: + - SYS_ADMIN + ports: + - "4096:4096" + devices: + - /dev/fuse:/dev/fuse + environment: + - TZ=Europe/Paris + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte ++ - /path/to/your/directory:/mydata +``` + +After updating the `docker-compose.yml` file, restart the Zerobyte container to apply the changes: + +```bash +docker compose down +docker compose up -d +``` + +Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path. ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true) @@ -121,9 +178,36 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov rclone listremotes ``` -4. **Mount the rclone config into the Zerobyte container** using the example: [examples/rclone-config-mount/README.md](examples/rclone-config-mount/README.md) +4. **Mount the rclone config into the Zerobyte container** by updating your `docker-compose.yml`: -5. **Create a repository** in Zerobyte: + ```diff + services: + zerobyte: + image: ghcr.io/nicotsx/zerobyte:v0.19 + container_name: zerobyte + restart: unless-stopped + cap_add: + - SYS_ADMIN + ports: + - "4096:4096" + devices: + - /dev/fuse:/dev/fuse + environment: + - TZ=Europe/Paris + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/lib/zerobyte:/var/lib/zerobyte + + - ~/.config/rclone:/root/.config/rclone + ``` + +5. **Restart the Zerobyte container**: + + ```bash + docker compose down + docker compose up -d + ``` + +6. **Create a repository** in Zerobyte: - Select "rclone" as the repository type - Choose your configured remote from the dropdown - Specify the path within your remote (e.g., `backups/zerobyte`) From e9fb5862f7a0c79a56c803318cee89c5dc0d903f Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:25:21 +0100 Subject: [PATCH 08/18] Controllers tests (#187) * test: backups service * refactor: create hono app in a separate file To avoid side effects like db migration or startup scripts when testing test(backups): add security tests to the backups controller * ci: run typechecks, build and tests on PR * test: controllers security tests * chore: update lock file * refactor: pr feedbacks --- .env.test | 1 + .../actions/install-dependencies/action.yml | 15 ++ .github/workflows/ci.yml | 23 +-- AGENTS.md | 10 ++ app/server/app.ts | 87 ++++++++++ app/server/core/constants.ts | 2 +- app/server/db/schema.ts | 3 + app/server/index.ts | 79 +-------- app/server/jobs/backup-execution.ts | 5 +- .../__tests__/backups.controller.test.ts | 126 +++++++++++++++ .../backups/__tests__/backups.service.test.ts | 151 ++++++++++++++++++ .../modules/backups/backups.controller.ts | 4 +- app/server/modules/backups/backups.service.ts | 2 - .../__tests__/events.controller.test.ts | 53 ++++++ .../notifications.controller.test.ts | 110 +++++++++++++ .../__tests__/repositories.controller.test.ts | 105 ++++++++++++ .../__tests__/system.controller.test.ts | 89 +++++++++++ .../__tests__/volumes.controller.test.ts | 104 ++++++++++++ app/server/utils/restic.ts | 3 +- app/server/utils/spawn.ts | 4 +- app/test/helpers/auth.ts | 24 +++ app/test/helpers/backup.ts | 16 ++ app/test/helpers/repository.ts | 20 +++ app/test/helpers/restic.ts | 18 +++ app/test/helpers/volume.ts | 21 +++ app/test/setup.ts | 19 +++ bun.lock | 72 +++++++-- package.json | 5 +- 28 files changed, 1063 insertions(+), 108 deletions(-) create mode 100644 .env.test create mode 100644 .github/actions/install-dependencies/action.yml create mode 100644 app/server/app.ts create mode 100644 app/server/modules/backups/__tests__/backups.controller.test.ts create mode 100644 app/server/modules/backups/__tests__/backups.service.test.ts create mode 100644 app/server/modules/events/__tests__/events.controller.test.ts create mode 100644 app/server/modules/notifications/__tests__/notifications.controller.test.ts create mode 100644 app/server/modules/repositories/__tests__/repositories.controller.test.ts create mode 100644 app/server/modules/system/__tests__/system.controller.test.ts create mode 100644 app/server/modules/volumes/__tests__/volumes.controller.test.ts create mode 100644 app/test/helpers/auth.ts create mode 100644 app/test/helpers/backup.ts create mode 100644 app/test/helpers/repository.ts create mode 100644 app/test/helpers/restic.ts create mode 100644 app/test/helpers/volume.ts create mode 100644 app/test/setup.ts diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..a8c805e0 --- /dev/null +++ b/.env.test @@ -0,0 +1 @@ +DATABASE_URL=:memory: diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml new file mode 100644 index 00000000..36b4abec --- /dev/null +++ b/.github/actions/install-dependencies/action.yml @@ -0,0 +1,15 @@ +name: Install dependencies + +description: Install dependencies + +runs: + using: "composite" + steps: + - uses: oven-sh/setup-bun@v2 + name: Install Bun + with: + bun-version: "1.3.5" + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb4c335..ad288622 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,18 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{ github.ref }} - - name: Scan current project - id: scan - uses: anchore/scan-action@v7 - with: - path: "." + - name: Install dependencies + uses: "./.github/actions/install-dependencies" - - name: upload Anchore scan SARIF report - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: ${{ steps.scan.outputs.sarif }} + - name: Run type checks + shell: bash + run: bun run tsc + + - name: Run tests + shell: bash + run: bun run test --ci --coverage + + - name: Build project + shell: bash + run: bun run build diff --git a/AGENTS.md b/AGENTS.md index 6ba3fcd9..92f7ff18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,16 @@ This is a unified application with the following structure: bun run tsc ``` +### Testing + +```bash +# Run all tests +bun run test + +# Run a specific test file +bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts +``` + ### Building ```bash diff --git a/app/server/app.ts b/app/server/app.ts new file mode 100644 index 00000000..b2d12d02 --- /dev/null +++ b/app/server/app.ts @@ -0,0 +1,87 @@ +import { Scalar } from "@scalar/hono-api-reference"; +import { Hono } from "hono"; +import { logger as honoLogger } from "hono/logger"; +import { secureHeaders } from "hono/secure-headers"; +import { rateLimiter } from "hono-rate-limiter"; +import { openAPIRouteHandler } from "hono-openapi"; +import { authController } from "./modules/auth/auth.controller"; +import { requireAuth } from "./modules/auth/auth.middleware"; +import { repositoriesController } from "./modules/repositories/repositories.controller"; +import { systemController } from "./modules/system/system.controller"; +import { volumeController } from "./modules/volumes/volume.controller"; +import { backupScheduleController } from "./modules/backups/backups.controller"; +import { eventsController } from "./modules/events/events.controller"; +import { notificationsController } from "./modules/notifications/notifications.controller"; +import { handleServiceError } from "./utils/errors"; +import { logger } from "./utils/logger"; +import { config } from "./core/config"; + +export const generalDescriptor = (app: Hono) => + openAPIRouteHandler(app, { + documentation: { + info: { + title: "Zerobyte API", + version: "1.0.0", + description: "API for managing volumes", + }, + servers: [{ url: `http://${config.serverIp}:4096`, description: "Development Server" }], + }, + }); + +export const scalarDescriptor = Scalar({ + title: "Zerobyte API Docs", + pageTitle: "Zerobyte API Docs", + url: "/api/v1/openapi.json", +}); + +export const createApp = () => { + const app = new Hono(); + + if (config.environment !== "test") { + app.use(honoLogger()); + } + + app + .use(secureHeaders()) + .use( + rateLimiter({ + windowMs: 60 * 5 * 1000, + limit: 1000, + keyGenerator: (c) => c.req.header("x-forwarded-for") ?? "", + skip: () => { + return config.__prod__ === false; + }, + }), + ) + .get("healthcheck", (c) => c.json({ status: "ok" })) + .route("/api/v1/auth", authController) + .use("/api/v1/volumes/*", requireAuth) + .use("/api/v1/repositories/*", requireAuth) + .use("/api/v1/backups/*", requireAuth) + .use("/api/v1/notifications/*", requireAuth) + .use("/api/v1/system/*", requireAuth) + .use("/api/v1/events/*", requireAuth) + .route("/api/v1/volumes", volumeController) + .route("/api/v1/repositories", repositoriesController) + .route("/api/v1/backups", backupScheduleController) + .route("/api/v1/notifications", notificationsController) + .route("/api/v1/system", systemController) + .route("/api/v1/events", eventsController); + + app.get("/api/v1/openapi.json", generalDescriptor(app)); + app.get("/api/v1/docs", requireAuth, scalarDescriptor); + + app.onError((err, c) => { + logger.error(`${c.req.url}: ${err.message}`); + + if (err.cause instanceof Error) { + logger.error(err.cause.message); + } + + const { status, message } = handleServiceError(err); + + return c.json({ message }, status); + }); + + return app; +}; diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index a59f558d..98a38724 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -1,7 +1,7 @@ export const OPERATION_TIMEOUT = 5000; export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes"; export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; -export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db"; +export const DATABASE_URL = process.env.DATABASE_URL || "/var/lib/zerobyte/data/ironmount.db"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE]; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index cb94d29b..e35ca820 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -21,6 +21,7 @@ export const volumesTable = sqliteTable("volumes_table", { autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), }); export type Volume = typeof volumesTable.$inferSelect; +export type VolumeInsert = typeof volumesTable.$inferInsert; /** * Users Table @@ -61,6 +62,7 @@ export const repositoriesTable = sqliteTable("repositories_table", { updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); export type Repository = typeof repositoriesTable.$inferSelect; +export type RepositoryInsert = typeof repositoriesTable.$inferInsert; /** * Backup Schedules Table @@ -96,6 +98,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); +export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert; export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({ volume: one(volumesTable, { diff --git a/app/server/index.ts b/app/server/index.ts index 20139a11..d1c32098 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,89 +1,14 @@ import { createHonoServer } from "react-router-hono-server/bun"; -import { Scalar } from "@scalar/hono-api-reference"; -import { Hono } from "hono"; -import { logger as honoLogger } from "hono/logger"; -import { secureHeaders } from "hono/secure-headers"; -import { rateLimiter } from "hono-rate-limiter"; -import { openAPIRouteHandler } from "hono-openapi"; import { runDbMigrations } from "./db/db"; -import { authController } from "./modules/auth/auth.controller"; -import { requireAuth } from "./modules/auth/auth.middleware"; import { startup } from "./modules/lifecycle/startup"; import { migrateToShortIds } from "./modules/lifecycle/migration"; -import { repositoriesController } from "./modules/repositories/repositories.controller"; -import { systemController } from "./modules/system/system.controller"; -import { volumeController } from "./modules/volumes/volume.controller"; -import { backupScheduleController } from "./modules/backups/backups.controller"; -import { eventsController } from "./modules/events/events.controller"; -import { notificationsController } from "./modules/notifications/notifications.controller"; -import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; import { shutdown } from "./modules/lifecycle/shutdown"; import { REQUIRED_MIGRATIONS } from "./core/constants"; import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; -import { config } from "./core/config"; +import { createApp } from "./app"; -export const generalDescriptor = (app: Hono) => - openAPIRouteHandler(app, { - documentation: { - info: { - title: "Zerobyte API", - version: "1.0.0", - description: "API for managing volumes", - }, - servers: [{ url: `http://${config.serverIp}:4096`, description: "Development Server" }], - }, - }); - -export const scalarDescriptor = Scalar({ - title: "Zerobyte API Docs", - pageTitle: "Zerobyte API Docs", - url: "/api/v1/openapi.json", -}); - -const app = new Hono() - .use(honoLogger()) - .use(secureHeaders()) - .use( - rateLimiter({ - windowMs: 60 * 5 * 1000, - limit: 1000, - keyGenerator: (c) => c.req.header("x-forwarded-for") ?? "", - skip: () => { - return config.__prod__ === false; - }, - }), - ) - .get("healthcheck", (c) => c.json({ status: "ok" })) - .route("/api/v1/auth", authController) - .use("/api/v1/volumes/*", requireAuth) - .use("/api/v1/repositories/*", requireAuth) - .use("/api/v1/backups/*", requireAuth) - .use("/api/v1/notifications/*", requireAuth) - .use("/api/v1/system/*", requireAuth) - .use("/api/v1/events/*", requireAuth) - .route("/api/v1/volumes", volumeController) - .route("/api/v1/repositories", repositoriesController) - .route("/api/v1/backups", backupScheduleController) - .route("/api/v1/notifications", notificationsController) - .route("/api/v1/system", systemController) - .route("/api/v1/events", eventsController); - -// API documentation endpoints require authentication -app.get("/api/v1/openapi.json", requireAuth, generalDescriptor(app)); -app.get("/api/v1/docs", requireAuth, scalarDescriptor); - -app.onError((err, c) => { - logger.error(`${c.req.url}: ${err.message}`); - - if (err.cause instanceof Error) { - logger.error(err.cause.message); - } - - const { status, message } = handleServiceError(err); - - return c.json({ message }, status); -}); +const app = createApp(); runDbMigrations(); diff --git a/app/server/jobs/backup-execution.ts b/app/server/jobs/backup-execution.ts index 821333ef..c38947a6 100644 --- a/app/server/jobs/backup-execution.ts +++ b/app/server/jobs/backup-execution.ts @@ -1,6 +1,5 @@ import { Job } from "../core/scheduler"; import { backupsService } from "../modules/backups/backups.service"; -import { toMessage } from "../utils/errors"; import { logger } from "../utils/logger"; export class BackupExecutionJob extends Job { @@ -17,8 +16,8 @@ export class BackupExecutionJob extends Job { logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`); for (const scheduleId of scheduleIds) { - backupsService.executeBackup(scheduleId).catch((error) => { - logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`); + backupsService.executeBackup(scheduleId).catch((err) => { + logger.error(`Error executing backup for schedule ${scheduleId}:`, err); }); } diff --git a/app/server/modules/backups/__tests__/backups.controller.test.ts b/app/server/modules/backups/__tests__/backups.controller.test.ts new file mode 100644 index 00000000..3c415004 --- /dev/null +++ b/app/server/modules/backups/__tests__/backups.controller.test.ts @@ -0,0 +1,126 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("backups security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/backups"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/backups", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/backups", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/backups" }, + { method: "GET", path: "/api/v1/backups/1" }, + { method: "GET", path: "/api/v1/backups/volume/1" }, + { method: "POST", path: "/api/v1/backups" }, + { method: "PATCH", path: "/api/v1/backups/1" }, + { method: "DELETE", path: "/api/v1/backups/1" }, + { method: "POST", path: "/api/v1/backups/1/run" }, + { method: "POST", path: "/api/v1/backups/1/stop" }, + { method: "POST", path: "/api/v1/backups/1/forget" }, + { method: "GET", path: "/api/v1/backups/1/notifications" }, + { method: "PUT", path: "/api/v1/backups/1/notifications" }, + { method: "GET", path: "/api/v1/backups/1/mirrors" }, + { method: "PUT", path: "/api/v1/backups/1/mirrors" }, + { method: "GET", path: "/api/v1/backups/1/mirrors/compatibility" }, + { method: "POST", path: "/api/v1/backups/reorder" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a schedule exists when unauthenticated", async () => { + const res = await app.request("/api/v1/backups/999999"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should not disclose if a volume exists when unauthenticated", async () => { + const res = await app.request("/api/v1/backups/volume/999999"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for malformed schedule ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/backups/not-a-number", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + }); + + test("should return 404 for non-existent schedule ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/backups/999999", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Backup schedule not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/backups", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts new file mode 100644 index 00000000..8fa5ecd7 --- /dev/null +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -0,0 +1,151 @@ +import { test, describe, mock, expect } from "bun:test"; +import { backupsService } from "../backups.service"; +import { createTestVolume } from "~/test/helpers/volume"; +import { createTestBackupSchedule } from "~/test/helpers/backup"; +import { createTestRepository } from "~/test/helpers/repository"; +import { generateBackupOutput } from "~/test/helpers/restic"; +import { beforeEach } from "bun:test"; + +const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0 })); + +mock.module("~/server/utils/spawn", () => ({ + safeSpawn: resticBackupMock, +})); + +beforeEach(() => { + resticBackupMock.mockClear(); +}); + +describe("execute backup", () => { + test("should correctly set next backup time", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + cronExpression: "*/5 * * * *", + }); + expect(schedule.nextBackupAt).toBeNull(); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.nextBackupAt).not.toBeNull(); + + const nextBackupAt = new Date(updatedSchedule.nextBackupAt ?? 0); + const now = new Date(); + + expect(nextBackupAt.getTime()).toBeGreaterThanOrEqual(now.getTime()); + expect(nextBackupAt.getTime() - now.getTime()).toBeLessThanOrEqual(5 * 60 * 1000); + }); + + test("should skip backup if schedule is disabled", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + }); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + expect(resticBackupMock).not.toHaveBeenCalled(); + }); + + test("should execute backup if schedule is disabled but the run is manual", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }), + ); + + // act + await backupsService.executeBackup(schedule.id, true); + + // assert + expect(resticBackupMock).toHaveBeenCalled(); + }); + + test("should skip the backup if the previous one is still running", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }); + }); + + // act + backupsService.executeBackup(schedule.id); + await new Promise((resolve) => setTimeout(resolve, 10)); + await backupsService.executeBackup(schedule.id); + + // assert + expect(resticBackupMock).toHaveBeenCalledTimes(1); + }); + + test("should set the backup status to failed if restic returns a 3 exit code", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 3, stdout: generateBackupOutput(), stderr: "Some error occurred" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("warning"); + }); + + test("should set the backup status to failed if restic returns a non zero exit code", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 1, stdout: generateBackupOutput(), stderr: "Some error occurred" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("error"); + }); +}); diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 653cd659..e9fa9f7a 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -86,8 +86,8 @@ export const backupScheduleController = new Hono() .post("/:scheduleId/run", runBackupNowDto, async (c) => { const scheduleId = c.req.param("scheduleId"); - backupsService.executeBackup(Number(scheduleId), true).catch((error) => { - console.error("Backup execution failed:", error); + backupsService.executeBackup(Number(scheduleId), true).catch((err) => { + console.error(`Error executing manual backup for schedule ${scheduleId}:`, err); }); return c.json({ success: true }, 200); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 584fada7..bb28f29a 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -363,8 +363,6 @@ const executeBackup = async (scheduleId: number, manual = false) => { .catch((notifError) => { logger.error(`Failed to send backup failure notification: ${toMessage(notifError)}`); }); - - throw error; } finally { runningBackups.delete(scheduleId); } diff --git a/app/server/modules/events/__tests__/events.controller.test.ts b/app/server/modules/events/__tests__/events.controller.test.ts new file mode 100644 index 00000000..c40793fe --- /dev/null +++ b/app/server/modules/events/__tests__/events.controller.test.ts @@ -0,0 +1,53 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("events security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/events"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/events", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/events", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [{ method: "GET", path: "/api/v1/events" }]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); +}); diff --git a/app/server/modules/notifications/__tests__/notifications.controller.test.ts b/app/server/modules/notifications/__tests__/notifications.controller.test.ts new file mode 100644 index 00000000..02834267 --- /dev/null +++ b/app/server/modules/notifications/__tests__/notifications.controller.test.ts @@ -0,0 +1,110 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("notifications security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/notifications/destinations"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/notifications/destinations", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/notifications/destinations", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/notifications/destinations" }, + { method: "POST", path: "/api/v1/notifications/destinations" }, + { method: "GET", path: "/api/v1/notifications/destinations/1" }, + { method: "PATCH", path: "/api/v1/notifications/destinations/1" }, + { method: "DELETE", path: "/api/v1/notifications/destinations/1" }, + { method: "POST", path: "/api/v1/notifications/destinations/1/test" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a destination exists when unauthenticated", async () => { + const res = await app.request("/api/v1/notifications/destinations/999999"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for malformed destination ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/notifications/destinations/not-a-number", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + }); + + test("should return 404 for non-existent destination ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/notifications/destinations/999999", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Notification destination not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/notifications/destinations", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts new file mode 100644 index 00000000..d7afc68d --- /dev/null +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -0,0 +1,105 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("repositories security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/repositories"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/repositories", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/repositories", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/repositories" }, + { method: "POST", path: "/api/v1/repositories" }, + { method: "GET", path: "/api/v1/repositories/rclone-remotes" }, + { method: "GET", path: "/api/v1/repositories/test-repo" }, + { method: "DELETE", path: "/api/v1/repositories/test-repo" }, + { method: "GET", path: "/api/v1/repositories/test-repo/snapshots" }, + { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" }, + { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/files" }, + { method: "POST", path: "/api/v1/repositories/test-repo/restore" }, + { method: "POST", path: "/api/v1/repositories/test-repo/doctor" }, + { method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" }, + { method: "PATCH", path: "/api/v1/repositories/test-repo" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a repository exists when unauthenticated", async () => { + const res = await app.request("/api/v1/repositories/non-existent-repo"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for non-existent repository", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/repositories/non-existent-repo", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Repository not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/repositories", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts new file mode 100644 index 00000000..5ce3d51e --- /dev/null +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -0,0 +1,89 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("system security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/system/info"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/system/info", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/system/info", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/system/info" }, + { method: "POST", path: "/api/v1/system/restic-password" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("input validation", () => { + test("should return 400 for invalid payload on restic-password", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/system/restic-password", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + }); + + test("should return 401 for incorrect password on restic-password", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/system/restic-password", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: "wrong-password", + }), + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Incorrect password"); + }); + }); +}); diff --git a/app/server/modules/volumes/__tests__/volumes.controller.test.ts b/app/server/modules/volumes/__tests__/volumes.controller.test.ts new file mode 100644 index 00000000..04b05776 --- /dev/null +++ b/app/server/modules/volumes/__tests__/volumes.controller.test.ts @@ -0,0 +1,104 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("volumes security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/volumes"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/volumes", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/volumes", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/volumes" }, + { method: "POST", path: "/api/v1/volumes" }, + { method: "POST", path: "/api/v1/volumes/test-connection" }, + { method: "DELETE", path: "/api/v1/volumes/test-volume" }, + { method: "GET", path: "/api/v1/volumes/test-volume" }, + { method: "PUT", path: "/api/v1/volumes/test-volume" }, + { method: "POST", path: "/api/v1/volumes/test-volume/mount" }, + { method: "POST", path: "/api/v1/volumes/test-volume/unmount" }, + { method: "POST", path: "/api/v1/volumes/test-volume/health-check" }, + { method: "GET", path: "/api/v1/volumes/test-volume/files" }, + { method: "GET", path: "/api/v1/volumes/filesystem/browse" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a volume exists when unauthenticated", async () => { + const res = await app.request("/api/v1/volumes/non-existent-volume"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for non-existent volume", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/volumes/non-existent-volume", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Volume not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/volumes", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index ebd52e23..0e1a7330 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -29,6 +29,7 @@ const backupOutputSchema = type({ total_duration: "number", snapshot_id: "string", }); +export type BackupOutput = typeof backupOutputSchema.infer; const snapshotInfoSchema = type({ gid: "number?", @@ -344,7 +345,7 @@ const backup = async ( throw new ResticError(res.exitCode, res.stderr.toString()); } - const lastLine = stdout.trim(); + const lastLine = (stdout || res.stdout).trim(); let summaryLine = ""; try { const resSummary = JSON.parse(lastLine ?? "{}"); diff --git a/app/server/utils/spawn.ts b/app/server/utils/spawn.ts index 6ef86cba..c2b9246b 100644 --- a/app/server/utils/spawn.ts +++ b/app/server/utils/spawn.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; -interface Params { +export interface SafeSpawnParams { command: string; args: string[]; env?: NodeJS.ProcessEnv; @@ -18,7 +18,7 @@ type SpawnResult = { stderr: string; }; -export const safeSpawn = (params: Params) => { +export const safeSpawn = (params: SafeSpawnParams) => { const { command, args, env = {}, signal, ...callbacks } = params; return new Promise((resolve) => { diff --git a/app/test/helpers/auth.ts b/app/test/helpers/auth.ts new file mode 100644 index 00000000..fba04ccf --- /dev/null +++ b/app/test/helpers/auth.ts @@ -0,0 +1,24 @@ +import { authService } from "~/server/modules/auth/auth.service"; +import { db } from "~/server/db/db"; +import { usersTable, sessionsTable } from "~/server/db/schema"; + +export async function createTestSession() { + const [existingUser] = await db.select().from(usersTable); + + if (!existingUser) { + await authService.register("testadmin", "testpassword"); + } + + const [user] = await db.select().from(usersTable); + + const sessionId = crypto.randomUUID(); + const expiresAt = Date.now() + 1000 * 60 * 60 * 24; // 24 hours + + await db.insert(sessionsTable).values({ + id: sessionId, + userId: user.id, + expiresAt, + }); + + return { sessionId, user }; +} diff --git a/app/test/helpers/backup.ts b/app/test/helpers/backup.ts new file mode 100644 index 00000000..4e8be3fe --- /dev/null +++ b/app/test/helpers/backup.ts @@ -0,0 +1,16 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { backupSchedulesTable, type BackupScheduleInsert } from "~/server/db/schema"; + +export const createTestBackupSchedule = async (overrides: Partial = {}) => { + const backup: BackupScheduleInsert = { + name: faker.system.fileName(), + cronExpression: "0 0 * * *", + repositoryId: "repo_123", + volumeId: 1, + ...overrides, + }; + + const data = await db.insert(backupSchedulesTable).values(backup).returning(); + return data[0]; +}; diff --git a/app/test/helpers/repository.ts b/app/test/helpers/repository.ts new file mode 100644 index 00000000..0a15c59e --- /dev/null +++ b/app/test/helpers/repository.ts @@ -0,0 +1,20 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { repositoriesTable, type RepositoryInsert } from "~/server/db/schema"; + +export const createTestRepository = async (overrides: Partial = {}) => { + const repository: RepositoryInsert = { + id: faker.string.alphanumeric(6), + name: faker.string.alphanumeric(10), + shortId: faker.string.alphanumeric(6), + config: { + name: "test-repo", + backend: "local", + }, + type: "local", + ...overrides, + }; + + const data = await db.insert(repositoriesTable).values(repository).returning(); + return data[0]; +}; diff --git a/app/test/helpers/restic.ts b/app/test/helpers/restic.ts new file mode 100644 index 00000000..b5732019 --- /dev/null +++ b/app/test/helpers/restic.ts @@ -0,0 +1,18 @@ +export const generateBackupOutput = () => { + return JSON.stringify({ + message_type: "summary", + files_new: 10, + files_changed: 5, + files_unmodified: 85, + dirs_new: 2, + dirs_changed: 1, + dirs_unmodified: 17, + data_blobs: 20, + tree_blobs: 5, + data_added: 1048576, + total_files_processed: 100, + total_bytes_processed: 2097152, + total_duration: 12.34, + snapshot_id: "abcd1234", + }); +}; diff --git a/app/test/helpers/volume.ts b/app/test/helpers/volume.ts new file mode 100644 index 00000000..0d63740d --- /dev/null +++ b/app/test/helpers/volume.ts @@ -0,0 +1,21 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { volumesTable, type VolumeInsert } from "~/server/db/schema"; + +export const createTestVolume = async (overrides: Partial = {}) => { + const volume: VolumeInsert = { + name: faker.system.fileName(), + config: { + backend: "directory", + path: `/mnt/volumes/${faker.system.fileName()}`, + }, + status: "mounted", + autoRemount: true, + shortId: faker.string.alphanumeric(6), + type: "directory", + ...overrides, + }; + + const data = await db.insert(volumesTable).values(volume).returning(); + return data[0]; +}; diff --git a/app/test/setup.ts b/app/test/setup.ts new file mode 100644 index 00000000..8791d42c --- /dev/null +++ b/app/test/setup.ts @@ -0,0 +1,19 @@ +import { beforeAll, mock } from "bun:test"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import path from "node:path"; +import { cwd } from "node:process"; +import { db } from "~/server/db/db"; + +mock.module("~/server/utils/logger", () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, +})); + +beforeAll(async () => { + const migrationsFolder = path.join(cwd(), "app", "drizzle"); + migrate(db, { migrationsFolder }); +}); diff --git a/bun.lock b/bun.lock index b430f4dc..82e3a37c 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@faker-js/faker": "^10.1.0", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", @@ -66,6 +67,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.7", "lightningcss": "^1.30.2", "tailwindcss": "^4.1.17", @@ -230,6 +232,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@faker-js/faker": ["@faker-js/faker@10.1.0", "", {}, "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -400,11 +404,13 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.5", "", { "os": "win32", "cpu": "x64" }, "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ=="], - "@scalar/core": ["@scalar/core@0.3.26", "", { "dependencies": { "@scalar/types": "0.5.2" } }, "sha512-CTwhU0zteyhFvlGbiErUC/nt7o4VMraXC4E52x2Cz+s/rgGrmS00pTYtnjO3NVQXXqjScq8BqMTqBZrHQEJhWw=="], + "@scalar/core": ["@scalar/core@0.3.28", "", { "dependencies": { "@scalar/types": "0.5.4" } }, "sha512-Ka+g5P3Fe4f9lsJcBxfI+XAgwMYeZRgzIBWw1/HBrDoRmH3rV/N//410MBKEYXUw7pWpS+dZPJANZRvU5jtxhw=="], - "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.28", "", { "dependencies": { "@scalar/core": "0.3.26" }, "peerDependencies": { "hono": "^4.10.3" } }, "sha512-RVY55Rpcy9/irv0SMSxuSlQ6wDyuP+iTDmTz/d5tGv/qqo8vEOJMdDRNftMUqdtqiZUAE8fXJnuDCTJ80ZztAQ=="], + "@scalar/helpers": ["@scalar/helpers@0.2.4", "", {}, "sha512-G7oGybO2QXM+MIxa4OZLXaYsS9mxKygFgOcY4UOXO6xpVoY5+8rahdak9cPk7HNj8RZSt4m/BveoT8g5BtnXxg=="], - "@scalar/types": ["@scalar/types@0.5.2", "", { "dependencies": { "nanoid": "5.1.5", "type-fest": "5.0.0", "zod": "^4.1.11" } }, "sha512-F5wyb/B/Mu56PpNqhgSfuKwiwVnmhNhzTOo+k5b++HvYhjwAnqnw8BzbDzwXhhn172IPw8kSkupA/vphw61IRA=="], + "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.30", "", { "dependencies": { "@scalar/core": "0.3.28" }, "peerDependencies": { "hono": "^4.10.3" } }, "sha512-a9cPluqfi1bgX2p7PJl/2O4jgPcoAl/ecSAe74TbPYIi27A0O0bkUBscO7WNRJhWJ1GVVxX8NvJTNlDxUNBlpg=="], + + "@scalar/types": ["@scalar/types@0.5.4", "", { "dependencies": { "@scalar/helpers": "0.2.4", "nanoid": "5.1.5", "type-fest": "5.0.0", "zod": "^4.1.11" } }, "sha512-5FNQH/zx3tnERzxfpErscPHfRxLCuhncmhFYiaSz196Xi2iG1YI08BtxTV2slfT6of52epJ/MrKerarplKf9eg=="], "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], @@ -454,7 +460,7 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="], - "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -492,6 +498,8 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -510,7 +518,7 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], @@ -522,7 +530,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -536,7 +544,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001760", "", {}, "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -574,10 +582,16 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], "cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -632,6 +646,10 @@ "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "dotenv-cli": ["dotenv-cli@11.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^17.1.0", "dotenv-expand": "^12.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww=="], + + "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], @@ -712,6 +730,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -720,7 +740,7 @@ "hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="], - "hono-rate-limiter": ["hono-rate-limiter@0.5.0", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" }, "optionalPeers": ["unstorage"] }, "sha512-Cps4udhDdPQ3O1Dm1fOzunI1iN1fW3TcVj1YvPdIjxHiHRitTsEz05q+BjgnLtcVDaDGbyuYyBaAxIy1DD1bMw=="], + "hono-rate-limiter": ["hono-rate-limiter@0.5.1", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" } }, "sha512-c3bUn6IRgFKjlouvRNBy+ZIPZ2CTyTt3fc0uat2bv3GiHmLM4jI0QJ6fHd3Tf4R6dO2sX2Uvl9Gtp+kny4KdXg=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -736,6 +756,8 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], @@ -748,6 +770,8 @@ "isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -788,7 +812,7 @@ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="], @@ -812,6 +836,8 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -826,12 +852,18 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -848,6 +880,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], @@ -872,6 +906,8 @@ "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], @@ -936,6 +972,10 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], @@ -994,10 +1034,16 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unstorage": ["unstorage@1.17.3", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.4", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1024,6 +1070,8 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], @@ -1040,6 +1088,8 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1082,6 +1132,8 @@ "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1090,6 +1142,8 @@ "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], diff --git a/package.json b/package.json index 2dc931fc..c6e73e60 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "start:prod": "docker compose down && docker compose up --build zerobyte-prod", "gen:api-client": "openapi-ts", "gen:migrations": "drizzle-kit generate", - "studio": "drizzle-kit studio" + "studio": "drizzle-kit studio", + "test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts" }, "overrides": { "esbuild": "^0.27.2" @@ -73,6 +74,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@faker-js/faker": "^10.1.0", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", @@ -81,6 +83,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.7", "lightningcss": "^1.30.2", "tailwindcss": "^4.1.17", From 3374e4f53de43163f1149de4dd1c552f18218e89 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:35:34 +0100 Subject: [PATCH 09/18] fix: prepend mount path in exclude patterns (#188) * fix: prepend mount path location in exclude patterns * test(backups): add tests suite for backend include & exclude patterns * test: fix leaking global module mock --- .../components/create-schedule-form.tsx | 6 +- .../modules/backends/utils/backend-utils.ts | 4 +- .../__tests__/backups.patterns.test.ts | 135 ++++++++++++++++++ .../backups/__tests__/backups.service.test.ts | 15 +- app/server/modules/backups/backups.service.ts | 23 ++- app/server/utils/restic.ts | 15 +- 6 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 app/server/modules/backups/__tests__/backups.patterns.test.ts diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 8f6de25c..2cf62e15 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -318,7 +318,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: onSelectionChange={handleSelectionChange} withCheckboxes={true} foldersOnly={false} - className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto" + className="flex-1 border rounded-md bg-card p-2 min-h-75 max-h-100 overflow-auto" /> {selectedPaths.size > 0 && (
@@ -342,7 +342,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: