diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e50498c7..ade7e813 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,6 +103,7 @@ jobs: dotnet tool install --global wix --version 7.* wix eula accept wix7 wix extension add --global WixToolset.UI.wixext/7.0.0 + wix extension add --global WixToolset.Util.wixext/7.0.0 # Build cargo directly (dist is already present from the artifact); keep the # default `embed-dashboard` feature so rust-embed bakes in apps/dashboard/dist. diff --git a/AGENTS.md b/AGENTS.md index 97c16426..732f4741 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,14 +71,13 @@ bun run format # Format all projects ## CLI -The backend binary (`vcms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). +The backend binary (`vcms`) is a clap CLI. With no subcommand it prints help. ```bash -vcms # run the server (alias for `vcms serve`) -vcms serve # run the server -vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) -vcms config show # print effective merged config (secrets redacted) -vcms config path # print resolved config file + search order +vcms # print help +vcms serve # run portable mode from ./vcms_data +vcms config show # print mode, root, bootstrap, and redacted secrets +vcms secrets reset --yes # replace trust root and invalidate credentials vcms admin reset-password --email U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups @@ -96,10 +95,7 @@ and requires `--yes`. JSON-RPC between stdin/stdout and the server's `/mcp` Streamable-HTTP endpoint, reading only `VCMS_MCP_TOKEN` (bearer) and `VCMS_MCP_URL` (default `http://127.0.0.1:3000`). So it works even when the data is owned by the OS-service -account. The installed service pins `VCMS_HOME` to a system dir so the daemon stores -everything under one owned root. - -Global flags (highest precedence): `--config `, `--bind `, `--database-url `, `--log-level `. +account. The installed service always uses its fixed system root. The server auto-migrates the database on every startup; there is no separate migrate command. @@ -114,135 +110,54 @@ The server auto-migrates the database on every startup; there is no separate mig ## Configuration -Non-secret settings live in a TOML config file; secrets stay in the environment (or `.env`). -Layers merge with precedence: **CLI flag > env var > config file > built-in default**. - -Config file search order (first existing wins; missing is fine): -1. `--config` flag / `VCMS_CONFIG` env -2. `./vcms.toml` (current dir) -3. the platform config dir (`config.toml`; `$VCMS_HOME/config.toml` in single-dir mode) — where `vcms config init` writes -4. `/etc/vcms/config.toml` +Bootstrap addresses and logging live in strict `config.toml`. The master key, +backup key, and optional database URL live in strict `secrets.toml`. There are no +server env overrides, CLI config overrides, search paths, or `.env` loading. ## Data directory -Resolution lives in `apps/backend/src/paths.rs` and has two layouts: - -**Split (default, interactive installs)** — files land in the platform-conventional -per-type directories via the `directories` crate (`ProjectDirs`): - -| File(s) | Dir | Linux | macOS | Windows | -|---------|-----|-------|-------|---------| -| `config.toml`, `secrets.toml`, `.env` | config | `~/.config/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\config` | -| `vcms.db`, `storage/`, `backups/` | data | `~/.local/share/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\data` | -| `search/` (derived, rebuildable) | cache | `~/.cache/vcms` | `~/Library/Caches/vcms` | `%LOCALAPPDATA%\vcms\cache` | -| `logs/` | state | `~/.local/state/vcms` | `~/Library/Application Support/vcms` | `%LOCALAPPDATA%\vcms\data` | - -(`logs/` uses the state dir where the platform has one — Linux — else the local data dir.) +Mode is selected only by native service registration. Installed mode uses +`/var/lib/vcms`, `/Library/Application Support/vcms`, or `C:\ProgramData\vcms`. +Without a registered service, portable mode uses `/vcms_data`. Detection +errors fail closed; directory existence and legacy paths never select a mode. -**Single** — everything nests under one root. Chosen (in precedence order) when: -1. **`$VCMS_HOME` is set** — forces the root explicitly. -2. **the system service home dir exists** — Linux `/var/lib/vcms`, macOS - `/Library/Application Support/vcms`, Windows `C:\ProgramData\vcms`. The - platform installer creates it (and leaves it behind on uninstall), so a plain - `vcms serve`/`admin`/`backup` **follows the service's data instead of forking to a - per-user split store**. This path is defined once in `paths::system_home()` and - imported by the Windows SCM host. -3. **a legacy `~/.vcms` exists** — an existing install keeps working untouched. - -Otherwise (dev/eval boxes with no service) files use the platform split dirs. +Both modes share one root layout: ```text -$VCMS_HOME/ # or system home, or ~/.vcms (legacy) - config.toml secrets.toml .env - vcms.db (+ -wal / -shm) logs/ storage/ backups/ search/ +/ + config.toml secrets.toml vcms.db + storage/ backups/ logs/ search/ ``` -When the active home is the system service home (owned by SYSTEM/root), the -data-touching commands (`serve`/`admin`/`backup`/`restore`) require elevation — a -non-elevated invocation **fails fast** with an "Administrator/root" hint (in -`paths::ensure`'s preflight) rather than silently forking to a second store. - -Secrets: on first `serve`/`admin`, a random `HMAC_SECRET` is generated and persisted to -`secrets.toml` (`apps/backend/src/secrets.rs`), then loaded by the server processes. -`vcms mcp stdio` does **not** load secrets, the database, or any data-dir file: it is a -thin HTTP proxy to the running server's `/mcp` (see CLI), forwarding `VCMS_MCP_TOKEN` as -the bearer; the server owns all disk I/O. +Fresh roots and strict files are auto-created. Existing malformed files are never +overwritten or silently repaired. A missing `secrets.toml` beside an existing +database is fatal. -Env-only secrets (never read from `config.toml` by convention, omitted from -`config init`): `DATABASE_URL`, `HMAC_SECRET`, `S3_ACCESS_KEY_ID`, -`S3_SECRET_ACCESS_KEY`, `BACKUP_S3_ACCESS_KEY_ID`, `BACKUP_S3_SECRET_ACCESS_KEY`, -`BACKUP_ENCRYPTION_KEY`. (`HMAC_SECRET` and a random backup encryption -key are auto-persisted to `secrets.toml`; the others remain env-only.) - -Sample `config.toml` (generate with `vcms config init`): +Sample `config.toml`: ```toml -bind_address = "0.0.0.0:3000" -grpc_bind_address = "0.0.0.0:50051" -max_upload_size_mb = 50 -cookie_secure = false -session_lifetime_hours = 24 -db_max_connections = 10 -rate_limit_max_requests = 100 -mcp_enabled = true -mcp_allowed_hosts = ["localhost", "127.0.0.1"] +[server] +http_address = "127.0.0.1:3000" +grpc_address = "127.0.0.1:50051" [log] -level = "cms=debug,vcms=debug,tower_http=debug,axum=debug" -output = "stdout" # stdout | file -format = "pretty" # pretty | json -annotations = false -dir = "logs" +level = "cms=info,vcms=info" +output = "file" ``` +`secrets.toml` contains `master_key`, `backup_encryption_key`, and optional +`database_url`. Owner settings and encrypted integration credentials live in the +database. Fixed filesystem paths and internal tuning constants are not configurable. + ## Environment Variables -Every non-secret key below can also be set in `config.toml` (env still overrides the file). -Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→`log.output`, -`LOG_FORMAT`→`log.format`, `LOG_ANNOTATIONS`→`log.annotations`, `LOG_DIR`→`log.dir`. +The server ignores environment configuration. Only the diskless MCP stdio client +reads environment variables: | Variable | Default | Description | |----------|---------|-------------| -| `VCMS_CONFIG` | - | Explicit config file path (same as `--config`) | -| `VCMS_HOME` | - | If set, forces single-dir mode: db/config/secrets/logs/storage all nest under this root (else files use the platform split dirs) | -| `VCMS_MCP_TOKEN` | - | `vcms_site_*` access token forwarded by `vcms mcp stdio` as the bearer credential (required for stdio) | -| `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server's base URL that `vcms mcp stdio` proxies to (`{url}/mcp`) | -| `DATABASE_URL` | `sqlite:///vcms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | -| `HMAC_SECRET` | auto | HMAC key for token lookup (required; auto-generated to `secrets.toml`, env overrides) | -| `BIND_ADDRESS` | `0.0.0.0:3000` | REST API listen address | -| `GRPC_BIND_ADDRESS` | `0.0.0.0:50051` | gRPC server listen address | -| `STORAGE_FS_PATH` | - | Filesystem storage path | -| `S3_ACCESS_KEY_ID` | - | S3 access key | -| `S3_SECRET_ACCESS_KEY` | - | S3 secret key | -| `S3_BUCKET` | - | S3 bucket name | -| `S3_REGION` | `us-east-1` | S3 region | -| `S3_ENDPOINT` | - | S3 endpoint (for S3-compatible services) | -| `S3_PUBLIC_URL` | - | Public URL for S3 assets | -| `BACKUP_ENABLED` | `true` | Run the scheduled-backup poller / allow backups | -| `BACKUP_DESTINATION` | `filesystem` | Backup destination: `filesystem` or `s3` | -| `BACKUP_LOCAL_PATH` | `/backups` | Local backup dir (when destination is filesystem) | -| `BACKUP_ZSTD_LEVEL` | `12` | zstd compression level for backups | -| `BACKUP_DEFAULT_RETENTION` | `7` | Default "keep last N" for new schedules | -| `BACKUP_S3_BUCKET` / `_REGION` / `_ENDPOINT` / `_PUBLIC_URL` | - | S3 backup destination (non-secret parts) | -| `BACKUP_S3_ACCESS_KEY_ID` | - | S3 backup access key (secret, env-only) | -| `BACKUP_S3_SECRET_ACCESS_KEY` | - | S3 backup secret key (secret, env-only) | -| `BACKUP_ENCRYPTION_KEY` | auto | AES-256 backup key (hex); auto-generated to `secrets.toml` | -| `MAX_UPLOAD_SIZE_MB` | `50` | Max upload size in MB | -| `UPLOAD_TOKEN_EXPIRY_SECS` | `900` | Signed upload URL lifetime (seconds) | -| `COOKIE_SECURE` | `false` | Require HTTPS cookies | -| `DB_MAX_CONNECTIONS` | `10` | Max DB connections | -| `DB_MIN_CONNECTIONS` | `2` | Min DB connections | -| `RATE_LIMIT_MAX_REQUESTS` | `100` | Rate limit per window | -| `RATE_LIMIT_WINDOW_SECS` | `60` | Rate limit window | -| `VCMS_ENV` | - | `production` enables production security checks | -| `RUST_LOG` | `cms=debug,vcms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | -| `LOG_OUTPUT` | `stdout` | `stdout` or `file` (`[log] output`) | -| `LOG_FORMAT` | `pretty` | `pretty` or `json` (`[log] format`) | -| `LOG_ANNOTATIONS` | `false` | Include file + line numbers (`[log] annotations`) | -| `LOG_DIR` | `/logs` | Log directory when `output = file` (`[log] dir`) | - -**Note**: `HMAC_SECRET` is auto-generated and persisted to the config dir's -`secrets.toml` on first run. Set it explicitly via env to override. +| `VCMS_MCP_TOKEN` | required | Site access token forwarded as bearer auth | +| `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server base URL | ## Proto Compilation diff --git a/CLAUDE.md b/CLAUDE.md index 93eebca1..86dfeb27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,163 +64,74 @@ bun run format # Format all projects ## CLI -The backend binary (`vcms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). +The backend binary (`vcms`) is a clap CLI. With no subcommand it prints help. ```bash -vcms # run the server (alias for `vcms serve`) -vcms serve # run the server -vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) -vcms config show # print effective merged config (secrets redacted) -vcms config path # print resolved config file + search order +vcms serve # run portable mode; refuse when native service is installed +vcms config show # print mode, root, bootstrap, secret presence, DB settings +vcms secrets reset # destructive trust-root reset and recovery report vcms admin reset-password --email U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes +vcms mcp stdio # diskless proxy to a running server +vcms service status +vcms doctor ``` `backup`/`restore` run offline (no HTTP server) against the configured database — the disaster-recovery path when the instance won't boot. `restore` is destructive and requires `--yes`. -Global flags (highest precedence): `--config `, `--bind `, `--database-url `, `--log-level `. - The server auto-migrates the database on every startup; there is no separate migrate command. ## Configuration -Non-secret settings live in a TOML config file; secrets stay in the environment (or `.env`). -Layers merge with precedence: **CLI flag > env var > config file > built-in default**. - -Config file search order (first existing wins; missing is fine): -1. `--config` flag / `VCMS_CONFIG` env -2. `./vcms.toml` (current dir) -3. the platform config dir (`config.toml`; `$VCMS_HOME/config.toml` in single-dir mode) — where `vcms config init` writes -4. `/etc/vcms/config.toml` +Bootstrap settings live only in strict `/config.toml`; trust-root secrets and +the optional database URL live only in strict `/secrets.toml`. There is no +server environment or CLI override layer. ## Data directory -Resolution lives in `apps/backend/src/paths.rs` and has two layouts: - -**Split (default, interactive installs)** — files land in the platform-conventional -per-type directories via the `directories` crate (`ProjectDirs`): - -| File(s) | Dir | Linux | macOS | Windows | -|---------|-----|-------|-------|---------| -| `config.toml`, `secrets.toml`, `.env` | config | `~/.config/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\config` | -| `vcms.db`, `storage/`, `backups/` | data | `~/.local/share/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\data` | -| `search/` (derived, rebuildable) | cache | `~/.cache/vcms` | `~/Library/Caches/vcms` | `%LOCALAPPDATA%\vcms\cache` | -| `logs/` | state | `~/.local/state/vcms` | `~/Library/Application Support/vcms` | `%LOCALAPPDATA%\vcms\data` | - -(`logs/` uses the state dir where the platform has one — Linux — else the local data dir.) - -**Single** — everything nests under one root. Chosen (in precedence order) when: -1. **`$VCMS_HOME` is set** — forces the root explicitly. -2. **the system service home dir exists** — Linux `/var/lib/vcms`, macOS - `/Library/Application Support/vcms`, Windows `C:\ProgramData\vcms`. The - platform installer creates it (and leaves it behind on uninstall), so a plain - `vcms serve`/`admin`/`backup` **follows the service's data instead of forking to a - per-user split store**. This path is defined once in `paths::system_home()` and - imported by the Windows SCM host. -3. **a legacy `~/.vcms` exists** — an existing install keeps working untouched. - -Otherwise (dev/eval boxes with no service) files use the platform split dirs. +Resolution lives in `apps/backend/src/paths.rs`. Native service registration selects +installed mode and its fixed OS root. Without a registered service, portable mode +uses `/vcms_data`. Detection failures stop startup. ```text -$VCMS_HOME/ # or system home, or ~/.vcms (legacy) - config.toml secrets.toml .env +/ + config.toml secrets.toml vcms.db (+ -wal / -shm) logs/ storage/ backups/ search/ ``` -`vcms serve`/`admin` create the dirs they need on first run; `vcms mcp stdio` creates -nothing. When the active home is the system service home (owned by SYSTEM/root), the -data-touching commands (`serve`/`admin`/`backup`/`restore`) require elevation — a -non-elevated invocation **fails fast** with an "Administrator/root" hint (in -`paths::ensure`'s preflight) rather than silently forking to a second store. - -Secrets: on first `serve`/`admin`, a random `HMAC_SECRET` is generated -and persisted to `secrets.toml` (`apps/backend/src/secrets.rs`), then loaded by -the server processes. `vcms mcp stdio` does **not** load secrets, the database, or -any home-dir file: it is a thin HTTP proxy (see below) and the server it forwards to -owns all of those. +Installed roots are `/var/lib/vcms`, `/Library/Application Support/vcms`, and +`C:\ProgramData\vcms`. `vcms mcp stdio` creates nothing. -Env-only secrets (never read from `config.toml` by convention, omitted from -`config init`): `DATABASE_URL`, `HMAC_SECRET`, `S3_ACCESS_KEY_ID`, -`S3_SECRET_ACCESS_KEY`, `BACKUP_S3_ACCESS_KEY_ID`, `BACKUP_S3_SECRET_ACCESS_KEY`, -`BACKUP_ENCRYPTION_KEY`. (`HMAC_SECRET` and a random backup encryption -key are auto-persisted to `secrets.toml`; the others remain env-only.) +Fresh roots atomically create a master key and backup encryption key in +`secrets.toml`. An existing database without its secrets file is a hard error. +Domain-separated keys are derived for token indexing, webhook encryption, and +instance-setting encryption. Owner-managed settings and provider credentials live +in the database. -Sample `config.toml` (generate with `vcms config init`): +Bootstrap `config.toml`: ```toml -bind_address = "0.0.0.0:3000" -grpc_bind_address = "0.0.0.0:50051" -max_upload_size_mb = 50 -cookie_secure = false -session_lifetime_hours = 24 -db_max_connections = 10 -rate_limit_max_requests = 100 -search_enabled = true -mcp_enabled = true -mcp_allowed_hosts = ["localhost", "127.0.0.1"] +[server] +http_address = "127.0.0.1:3000" +grpc_address = "127.0.0.1:50051" [log] -level = "cms=debug,vcms=debug,tower_http=debug,axum=debug" -output = "stdout" # stdout | file -format = "pretty" # pretty | json -annotations = false -dir = "logs" +level = "cms=info,vcms=info" +output = "file" ``` ## Environment Variables -Every non-secret key below can also be set in `config.toml` (env still overrides the file). -Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→`log.output`, -`LOG_FORMAT`→`log.format`, `LOG_ANNOTATIONS`→`log.annotations`, `LOG_DIR`→`log.dir`. - | Variable | Default | Description | |----------|---------|-------------| -| `VCMS_CONFIG` | - | Explicit config file path (same as `--config`) | -| `VCMS_HOME` | - | If set, forces single-dir mode: db/config/secrets/logs/storage all nest under this root (else files use the platform split dirs) | | `VCMS_MCP_TOKEN` | - | `vcms_site_*` access token forwarded by `vcms mcp stdio` as the bearer credential (required for stdio) | | `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server's base URL that `vcms mcp stdio` proxies to (`{url}/mcp`) | -| `DATABASE_URL` | `sqlite:///vcms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | -| `HMAC_SECRET` | auto | HMAC key for token lookup (required; auto-generated to `secrets.toml`, env overrides) | -| `BIND_ADDRESS` | `0.0.0.0:3000` | REST API listen address | -| `GRPC_BIND_ADDRESS` | `0.0.0.0:50051` | gRPC server listen address | -| `STORAGE_FS_PATH` | - | Filesystem storage path | -| `S3_ACCESS_KEY_ID` | - | S3 access key | -| `S3_SECRET_ACCESS_KEY` | - | S3 secret key | -| `S3_BUCKET` | - | S3 bucket name | -| `S3_REGION` | `us-east-1` | S3 region | -| `S3_ENDPOINT` | - | S3 endpoint (for S3-compatible services) | -| `S3_PUBLIC_URL` | - | Public URL for S3 assets | -| `BACKUP_ENABLED` | `true` | Run the scheduled-backup poller / allow backups | -| `BACKUP_DESTINATION` | `filesystem` | Backup destination: `filesystem` or `s3` | -| `BACKUP_LOCAL_PATH` | `/backups` | Local backup dir (when destination is filesystem) | -| `BACKUP_ZSTD_LEVEL` | `12` | zstd compression level for backups | -| `BACKUP_DEFAULT_RETENTION` | `7` | Default "keep last N" for new schedules | -| `BACKUP_S3_BUCKET` / `_REGION` / `_ENDPOINT` / `_PUBLIC_URL` | - | S3 backup destination (non-secret parts) | -| `BACKUP_S3_ACCESS_KEY_ID` | - | S3 backup access key (secret, env-only) | -| `BACKUP_S3_SECRET_ACCESS_KEY` | - | S3 backup secret key (secret, env-only) | -| `BACKUP_ENCRYPTION_KEY` | auto | AES-256 backup key (hex); auto-generated to `secrets.toml` | -| `SEARCH_ENABLED` | `true` | Build/use the Tantivy full-text index for entry search (else SQL `LIKE`) | -| `SEARCH_INDEX_PATH` | `/search` | Directory for the Tantivy search index | -| `MAX_UPLOAD_SIZE_MB` | `50` | Max upload size in MB | -| `UPLOAD_TOKEN_EXPIRY_SECS` | `900` | Signed upload URL lifetime (seconds) | -| `COOKIE_SECURE` | `false` | Require HTTPS cookies | -| `DB_MAX_CONNECTIONS` | `10` | Max DB connections | -| `DB_MIN_CONNECTIONS` | `2` | Min DB connections | -| `RATE_LIMIT_MAX_REQUESTS` | `100` | Rate limit per window | -| `RATE_LIMIT_WINDOW_SECS` | `60` | Rate limit window | -| `VCMS_ENV` | - | `production` enables production security checks | -| `RUST_LOG` | `cms=debug,vcms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | -| `LOG_OUTPUT` | `stdout` | `stdout` or `file` (`[log] output`) | -| `LOG_FORMAT` | `pretty` | `pretty` or `json` (`[log] format`) | -| `LOG_ANNOTATIONS` | `false` | Include file + line numbers (`[log] annotations`) | -| `LOG_DIR` | `/logs` | Log directory when `output = file` (`[log] dir`) | - -**Note**: `HMAC_SECRET` is auto-generated and persisted to the config dir's -`secrets.toml` on first run. Set it explicitly via env to override. + +No server-side environment variables are supported. ## Proto Compilation diff --git a/Cargo.lock b/Cargo.lock index 2690fab2..2195f592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,15 +347,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -801,10 +792,7 @@ dependencies = [ "cookie", "croner", "dashmap", - "directories", - "dotenvy", "email_address", - "figment", "futures-util", "hex", "hmac", @@ -835,7 +823,7 @@ dependencies = [ "tokio-stream", "tokio-test", "tokio-util", - "toml 1.1.2+spec-1.1.0", + "toml", "tonic", "tonic-build", "tonic-health", @@ -852,6 +840,7 @@ dependencies = [ "utoipa-scalar", "uuid", "windows-service", + "windows-sys 0.61.2", "wiremock", "zstd", ] @@ -1280,27 +1269,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -1470,20 +1438,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic", - "pear", - "serde", - "toml 0.8.23", - "uncased", - "version_check", -] - [[package]] name = "filetime" version = "0.2.29" @@ -2200,12 +2154,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - [[package]] name = "inout" version = "0.2.2" @@ -2391,15 +2339,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - [[package]] name = "libsqlite3-sys" version = "0.37.0" @@ -2822,12 +2761,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "ordered-float" version = "5.3.0" @@ -2893,29 +2826,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3086,7 +2996,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit", ] [[package]] @@ -3098,19 +3008,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - [[package]] name = "profiling" version = "1.0.18" @@ -3442,17 +3339,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -3916,15 +3802,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -4751,18 +4628,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4771,20 +4636,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.3", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "winnow", ] [[package]] @@ -4796,20 +4652,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -4817,9 +4659,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.3", + "winnow", ] [[package]] @@ -4828,15 +4670,9 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -5158,15 +4994,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - [[package]] name = "unicase" version = "2.9.0" @@ -5750,15 +5577,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.3" @@ -5827,12 +5645,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.3" diff --git a/README.md b/README.md index ec28f7a3..c5bfc0ce 100644 --- a/README.md +++ b/README.md @@ -119,39 +119,29 @@ serve` instance must be running. MCP protocol messages use stdout; logs use stde ### Data directory -By default, runtime files go to the platform-conventional per-type directories -(resolved cross-platform via the `directories` crate): +Runtime mode is deterministic. If the native service is registered, commands use +the installed root. Otherwise `vcms serve` is portable and uses +`/vcms_data`. Directory existence never selects a mode. -| File(s) | Linux | macOS | Windows | -|---------|-------|-------|---------| -| `config.toml`, `secrets.toml`, `.env` | `~/.config/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\config` | -| `vcms.db`, `storage/`, `backups/` | `~/.local/share/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\data` | -| `search/` (rebuildable index) | `~/.cache/vcms` | `~/Library/Caches/vcms` | `%LOCALAPPDATA%\vcms\cache` | -| `logs/` | `~/.local/state/vcms` | `~/Library/Application Support/vcms` | `%LOCALAPPDATA%\vcms\data` | +| Mode | Root | +|------|------| +| Portable | `/vcms_data` | +| Installed Linux | `/var/lib/vcms` | +| Installed macOS | `/Library/Application Support/vcms` | +| Installed Windows | `C:\ProgramData\vcms` | -Set **`$VCMS_HOME`** to keep everything under a single root instead. When several -locations exist, the first match wins: **`$VCMS_HOME`** → a legacy `~/.vcms` -(honored automatically, so upgrades don't move your data) → the split per-type -defaults above. - -The platform service installers store daemon data under one system dir — -`/var/lib/vcms` (Linux), `/Library/Application Support/vcms` (macOS), or -`C:\ProgramData\vcms` (Windows). When this directory exists, the binary uses it -automatically. Data-touching commands require an elevated shell when service-owned -permissions restrict access. - -`vcms serve` creates what it needs on first run and generates `secrets.toml` if -absent. Environment variables (`DATABASE_URL`, `HMAC_SECRET`, `STORAGE_FS_PATH`, -S3 settings, …) still override these defaults. +Both modes use one layout: `config.toml`, `secrets.toml`, `vcms.db`, `storage/`, +`backups/`, `logs/`, and `search/` beneath the root. Fresh roots are created +automatically. Existing malformed files are rejected, not repaired. Server env +overrides, config search paths, and legacy layouts are unsupported. ### Installed service operations -Native packages register `vcms` with systemd, launchd, or Windows SCM, but never -start a fresh installation automatically. Validate configuration and inspect state -with `vcms doctor` and `vcms service status`, then start it through the native service -manager. +Native packages register the hidden `vcms service run` entrypoint with systemd, +launchd, or Windows SCM. Inspect it with `vcms doctor`, `vcms config show`, and +`vcms service status`, then control it through the native service manager. `vcms doctor` checks resolved configuration, directory access, current database schema, listener availability, and execution identity without creating or migrating diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 6f1a095f..4de900f3 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -36,7 +36,6 @@ chrono = { version = "0.4", features = ["serde"] } bcrypt = "0.19" utoipa = { version = "5", features = ["axum_extras"] } utoipa-scalar = { version = "0.3", features = ["axum"] } -dotenvy = "0.15" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "avif"] } object_store = { version = "0.14", features = ["aws"] } bytes = "1" @@ -71,10 +70,8 @@ rmcp = { version = "2.0", features = ["server", "transport-io", "transport-strea tokio-util = { version = "0.7", features = ["rt"] } tokio-stream = { version = "0.1", features = ["net"] } schemars = "1" -clap = { version = "4.6.1", features = ["derive", "env"] } -figment = { version = "0.10.19", features = ["toml", "env"] } +clap = { version = "4.6.1", features = ["derive"] } toml = "1.1.2" -directories = "6.0.0" zstd = "0.13" tar = "0.4" croner = "3" @@ -83,6 +80,7 @@ tantivy = "0.26" # Windows: native Service Control Manager host for `vcms service run`. [target.'cfg(windows)'.dependencies] windows-service = "0.8" +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } [build-dependencies] tonic-build = "0.14" diff --git a/apps/backend/migrations/mysql/20260619000000_instance_settings.sql b/apps/backend/migrations/mysql/20260619000000_instance_settings.sql new file mode 100644 index 00000000..764888bf --- /dev/null +++ b/apps/backend/migrations/mysql/20260619000000_instance_settings.sql @@ -0,0 +1,8 @@ +CREATE TABLE instance_settings ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL, + settings_json LONGTEXT NOT NULL, + credentials_encrypted LONGTEXT, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CHECK (id = 1) +); diff --git a/apps/backend/migrations/mysql/20260620000000_webhook_enabled.sql b/apps/backend/migrations/mysql/20260620000000_webhook_enabled.sql new file mode 100644 index 00000000..1cf81724 --- /dev/null +++ b/apps/backend/migrations/mysql/20260620000000_webhook_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE site_webhooks ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/apps/backend/migrations/postgres/20260619000000_instance_settings.sql b/apps/backend/migrations/postgres/20260619000000_instance_settings.sql new file mode 100644 index 00000000..baf02f1a --- /dev/null +++ b/apps/backend/migrations/postgres/20260619000000_instance_settings.sql @@ -0,0 +1,7 @@ +CREATE TABLE instance_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + settings_json TEXT NOT NULL, + credentials_encrypted TEXT, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); diff --git a/apps/backend/migrations/postgres/20260620000000_webhook_enabled.sql b/apps/backend/migrations/postgres/20260620000000_webhook_enabled.sql new file mode 100644 index 00000000..1cf81724 --- /dev/null +++ b/apps/backend/migrations/postgres/20260620000000_webhook_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE site_webhooks ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/apps/backend/migrations/sqlite/20260619000000_instance_settings.sql b/apps/backend/migrations/sqlite/20260619000000_instance_settings.sql new file mode 100644 index 00000000..f26e15d3 --- /dev/null +++ b/apps/backend/migrations/sqlite/20260619000000_instance_settings.sql @@ -0,0 +1,7 @@ +CREATE TABLE instance_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + settings_json TEXT NOT NULL, + credentials_encrypted TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/apps/backend/migrations/sqlite/20260620000000_webhook_enabled.sql b/apps/backend/migrations/sqlite/20260620000000_webhook_enabled.sql new file mode 100644 index 00000000..436a3d5d --- /dev/null +++ b/apps/backend/migrations/sqlite/20260620000000_webhook_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE site_webhooks ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; diff --git a/apps/backend/project.json b/apps/backend/project.json index 7a619b04..86b8a01c 100644 --- a/apps/backend/project.json +++ b/apps/backend/project.json @@ -28,7 +28,7 @@ "executor": "nx:run-commands", "dependsOn": ["build-dev"], "options": { - "command": "cargo run --locked", + "command": "cargo run --locked -- serve", "env": { "SKIP_DASHBOARD_BUILD": "1" } }, "cache": false @@ -37,8 +37,7 @@ "executor": "nx:run-commands", "dependsOn": [], "options": { - "command": "cargo run --locked --no-default-features", - "env": { "DEV_MODE": "1" } + "command": "cargo run --locked --no-default-features -- serve" }, "cache": false }, diff --git a/apps/backend/src/cli.rs b/apps/backend/src/cli.rs index 5eedb86f..90fe97a4 100644 --- a/apps/backend/src/cli.rs +++ b/apps/backend/src/cli.rs @@ -2,71 +2,44 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; -/// CMS server and administrative command-line interface. #[derive(Parser, Debug, Default)] #[command( name = "vcms", version, about = "Velopulent CMS", - long_about = "Velopulent CMS — self-hosted headless content management system.\n\n\ - Runtime files default to the platform's per-type directories (config, data, \ - cache, state) via the `directories` crate. Set $VCMS_HOME to keep everything \ - under one root instead. OS packages set it from their service definitions. \ - `vcms serve` creates what it needs on first run and generates secrets if absent.", - after_help = "DATA DIRECTORIES (defaults; set $VCMS_HOME for a single root):\n \ - config dir config.toml, secrets.toml (0600 on unix), .env\n \ - data dir vcms.db (+ -wal / -shm), storage/, backups/\n \ - cache dir search/ (derived Tantivy index, rebuildable)\n \ - state dir logs/ (when [log] output = \"file\")\n\n\ - KEY ENVIRONMENT (env overrides config; CLI flags override env):\n \ - VCMS_HOME force single-root layout [default: platform split dirs]\n \ - DATABASE_URL sqlite/postgres/mysql URL [default: sqlite:///vcms.db]\n \ - HMAC_SECRET token-lookup HMAC key [auto-generated to secrets.toml]\n\n\ - DOCUMENTATION:\n \ - https://cms.velopulent.com/docs" + long_about = "Velopulent CMS — self-hosted headless content management system.\n\n`vcms serve` runs a portable instance from ./vcms_data. When the native service is installed, operational commands use its fixed system data root.", + after_help = "DOCUMENTATION:\n https://cms.velopulent.com/docs" )] pub struct Cli { - /// Path to a config file (overrides the search path). - #[arg(long, global = true, env = "VCMS_CONFIG", value_name = "PATH")] - pub config: Option, - - /// REST API bind address, e.g. 0.0.0.0:3000 (overrides config + env). - #[arg(long, global = true, value_name = "ADDR")] - pub bind: Option, - - /// Database URL, e.g. sqlite:path / postgres://… (overrides config + env) - /// [default: sqlite:///vcms.db]. - #[arg(long, global = true, value_name = "URL")] - pub database_url: Option, - - /// Log filter directive, e.g. "cms=info" (overrides config + env). - #[arg(long, global = true, value_name = "LEVEL")] - pub log_level: Option, - #[command(subcommand)] pub command: Option, } #[derive(Subcommand, Debug)] pub enum Command { - /// Run the server (default when no subcommand is given). + /// Run a portable server from ./vcms_data. Serve, /// Run Model Context Protocol transports. Mcp { #[command(subcommand)] transport: McpTransport, }, - /// Inspect or scaffold configuration. + /// Inspect effective configuration. Config { #[command(subcommand)] action: ConfigAction, }, + /// Manage the restricted instance trust root. + Secrets { + #[command(subcommand)] + action: SecretsAction, + }, /// Administrative operations. Admin { #[command(subcommand)] action: AdminAction, }, - /// Create or list backups (runs offline, without the HTTP server). + /// Create or list backups. Backup { #[command(subcommand)] action: BackupAction, @@ -78,22 +51,16 @@ pub enum Command { }, /// Validate configuration, storage, database, ports, and service context. Doctor, - /// Restore a backup artifact (runs offline; destructive — replaces data in scope). + /// Restore a backup artifact (destructive). Restore { - /// Path to the backup artifact (`.cmsbak`). #[arg(long, value_name = "PATH")] file: PathBuf, - /// Restore scope: `instance` (whole instance) or `site` (a single site). #[arg(long, default_value = "instance", value_name = "SCOPE")] scope: String, - /// Site id to restore (required when --scope site, or to extract one site - /// from an instance backup). #[arg(long, value_name = "SITE_ID")] site: Option, - /// Import the site under fresh ids instead of replacing in place. #[arg(long)] import_as_new: bool, - /// Skip the destructive-action confirmation prompt. #[arg(long)] yes: bool, }, @@ -101,25 +68,18 @@ pub enum Command { #[derive(Subcommand, Debug)] pub enum BackupAction { - /// Create a backup. Create { - /// `instance` (everything) or `site` (one site). #[arg(long, default_value = "instance", value_name = "SCOPE")] scope: String, - /// Site id (required when --scope site). #[arg(long, value_name = "SITE_ID")] site: Option, - /// Write the artifact to this file instead of the configured destination. #[arg(long, value_name = "PATH")] out: Option, - /// Exclude uploaded files from the backup. #[arg(long = "no-files")] no_files: bool, - /// Encrypt the artifact (requires a backup encryption key). #[arg(long)] encrypt: bool, }, - /// List recorded backups. List, } @@ -127,41 +87,31 @@ pub enum BackupAction { pub enum ServiceAction { /// Print normalized native service state and manager details. Status, - /// Internal entry point invoked by the Windows Service Control Manager. - /// - /// Not for direct use — the SCM launches this to host the server inside a - /// Windows service. Hidden from `--help`. + /// Internal native-service entry point. Not for direct use. #[command(hide = true)] - #[cfg(windows)] Run, } #[derive(Subcommand, Debug)] pub enum McpTransport { - /// Run MCP over stdin/stdout. Stdio, } #[derive(Subcommand, Debug)] pub enum ConfigAction { - /// Write a default config file (non-secret keys only). - Init { - /// Overwrite an existing config file. + Show, +} + +#[derive(Subcommand, Debug)] +pub enum SecretsAction { + Reset { #[arg(long)] - force: bool, - /// Write to this path instead of the default user config dir. - #[arg(long, value_name = "PATH")] - path: Option, + yes: bool, }, - /// Print the effective merged configuration (secrets redacted). - Show, - /// Print the resolved config file path and the search order. - Path, } #[derive(Subcommand, Debug)] pub enum AdminAction { - /// Reset a user's password. The user is identified by their unique login email. ResetPassword { #[arg(long, value_name = "EMAIL")] email: String, @@ -176,26 +126,24 @@ mod tests { use clap::Parser; #[test] - fn parses_mcp_stdio_command() { - let cli = Cli::try_parse_from(["vcms", "mcp", "stdio"]).expect("command should parse"); + fn no_subcommand_is_help_mode() { + assert!(Cli::try_parse_from(["vcms"]).unwrap().command.is_none()); + } + + #[test] + fn parses_operational_commands() { + let mcp = Cli::try_parse_from(["vcms", "mcp", "stdio"]).unwrap(); assert!(matches!( - cli.command, + mcp.command, Some(Command::Mcp { transport: McpTransport::Stdio }) )); - } - - #[test] - fn parses_operational_commands() { - let doctor = Cli::try_parse_from(["vcms", "doctor"]).unwrap(); - assert!(matches!(doctor.command, Some(Command::Doctor))); - - let status = Cli::try_parse_from(["vcms", "service", "status"]).unwrap(); + let service = Cli::try_parse_from(["vcms", "service", "run"]).unwrap(); assert!(matches!( - status.command, + service.command, Some(Command::Service { - action: ServiceAction::Status + action: ServiceAction::Run }) )); } diff --git a/apps/backend/src/config.rs b/apps/backend/src/config.rs index b5865f0c..d4443a1e 100644 --- a/apps/backend/src/config.rs +++ b/apps/backend/src/config.rs @@ -1,35 +1,82 @@ -use std::path::PathBuf; +use std::io::Write; -use figment::{ - Figment, - providers::{Env, Format, Serialized, Toml}, -}; -use serde::{Deserialize, Deserializer}; +use serde::{Deserialize, Serialize}; -use crate::cli::Cli; -use crate::paths; +use crate::paths::RuntimePaths; use crate::secrets; -#[derive(Clone, Debug, Default)] +pub const DEFAULT_LOG_LEVEL: &str = "cms=info,vcms=info"; +pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 50 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BootstrapConfig { + pub server: ServerConfig, + pub log: LogConfig, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + pub http_address: String, + pub grpc_address: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LogConfig { + pub level: String, + pub output: LogOutput, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LogOutput { + File, + Stdout, +} + +impl std::fmt::Display for LogOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::File => "file", + Self::Stdout => "stdout", + }) + } +} + +impl Default for BootstrapConfig { + fn default() -> Self { + Self { + server: ServerConfig { + http_address: "127.0.0.1:3000".to_owned(), + grpc_address: "127.0.0.1:50051".to_owned(), + }, + log: LogConfig { + level: DEFAULT_LOG_LEVEL.to_owned(), + output: LogOutput::File, + }, + } + } +} + +/// Runtime compatibility carrier. Values no longer come from environment or CLI; +/// bootstrap values come from `config.toml`, secrets from `secrets.toml`, and the +/// remaining fields are fixed defaults until DB-backed instance settings load. +#[derive(Clone, Debug)] pub struct Config { pub database_url: String, pub bind_address: String, pub grpc_bind_address: String, - - // Filesystem storage pub storage_fs_path: Option, - - // S3-compatible storage pub s3_access_key_id: Option, pub s3_secret_access_key: Option, pub s3_bucket: Option, pub s3_region: Option, pub s3_endpoint: Option, pub s3_public_url: Option, - - // Backup destination + options (independent of the site-files storage above) pub backup_enabled: bool, - pub backup_destination: String, // "filesystem" | "s3" + pub backup_destination: String, pub backup_local_path: Option, pub backup_zstd_level: i32, pub backup_default_retention: i64, @@ -39,213 +86,105 @@ pub struct Config { pub backup_s3_region: Option, pub backup_s3_endpoint: Option, pub backup_s3_public_url: Option, - /// AES-256 backup key (hex). From `BACKUP_ENCRYPTION_KEY` or persisted secrets. pub backup_encryption_key: Option, - - // Upload limits pub max_upload_size_bytes: usize, - /// Lifetime of signed upload URLs (seconds). pub upload_token_expiry_secs: i64, - - // Cookie security pub cookie_secure: bool, pub session_lifetime_hours: i64, pub public_registration_enabled: bool, pub allowed_origins: Vec, pub production: bool, - - // Database pool pub db_max_connections: u32, pub db_min_connections: u32, pub db_acquire_timeout_secs: u64, pub db_idle_timeout_secs: u64, - - // Rate limiting pub rate_limit_max_requests: u32, pub rate_limit_window_secs: u64, - - // Password hashing cost (bcrypt work factor) pub bcrypt_cost: u32, - // Trust reverse-proxy client-IP headers (X-Forwarded-For / X-Real-IP) pub trust_proxy_headers: bool, - // Allow webhooks to target private/loopback addresses (SSRF guard off) pub webhook_allow_private_targets: bool, - - // Hash secret for access token fast lookup - pub hmac_secret: String, - - // Full-text search (Tantivy) + pub token_index_key: String, + pub session_auth_key: String, + pub signed_upload_key: String, + pub webhook_encryption_key: String, pub search_enabled: bool, pub search_index_path: Option, - - // MCP configuration pub mcp_enabled: bool, pub mcp_allowed_hosts: Vec, pub mcp_allowed_origins: Vec, pub public_url: Option, - - // Logging pub log_level: String, pub log_output: String, - pub log_format: String, - pub log_annotations: bool, pub log_dir: String, } -// `cms` is the library crate (handlers/services/etc.); `vcms` is the binary crate -// (main.rs lifecycle/startup logs). Both must be enabled to see all of our logs. -static DEFAULT_LOG_LEVEL: &str = "cms=debug,vcms=debug,tower_http=debug,axum=debug"; - -/// Intermediate, fully-optional config deserialized from the merged figment -/// layers (defaults < TOML file < env vars < CLI flags). Converted into the -/// runtime [`Config`] by [`RawConfig::into_config`], which applies hardcoded -/// defaults and the secret-warning behavior. -#[derive(Debug, Default, Deserialize)] -struct RawConfig { - database_url: Option, - hmac_secret: Option, - bind_address: Option, - grpc_bind_address: Option, - - storage_fs_path: Option, - - s3_access_key_id: Option, - s3_secret_access_key: Option, - s3_bucket: Option, - s3_region: Option, - s3_endpoint: Option, - s3_public_url: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - backup_enabled: Option, - backup_destination: Option, - backup_local_path: Option, - backup_zstd_level: Option, - backup_default_retention: Option, - backup_s3_access_key_id: Option, - backup_s3_secret_access_key: Option, - backup_s3_bucket: Option, - backup_s3_region: Option, - backup_s3_endpoint: Option, - backup_s3_public_url: Option, - backup_encryption_key: Option, - - max_upload_size_mb: Option, - upload_token_expiry_secs: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - cookie_secure: Option, - session_lifetime_hours: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - public_registration_enabled: Option, - #[serde(default, deserialize_with = "de_opt_str_list")] - allowed_origins: Option>, - /// Maps the `VCMS_ENV` env var / `env` TOML key; "production" => production. - env: Option, - - db_max_connections: Option, - db_min_connections: Option, - db_acquire_timeout_secs: Option, - db_idle_timeout_secs: Option, - - rate_limit_max_requests: Option, - rate_limit_window_secs: Option, - - bcrypt_cost: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - trust_proxy_headers: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - webhook_allow_private_targets: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - search_enabled: Option, - search_index_path: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - mcp_enabled: Option, - #[serde(default, deserialize_with = "de_opt_str_list")] - mcp_allowed_hosts: Option>, - #[serde(default, deserialize_with = "de_opt_str_list")] - mcp_allowed_origins: Option>, - public_url: Option, - - log: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct RawLog { - level: Option, - output: Option, - format: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - annotations: Option, - dir: Option, -} - impl Config { - /// Load configuration by merging, in increasing precedence: - /// built-in defaults < config file < environment variables < CLI flags. - pub fn load(cli: &Cli) -> Result> { - let mut figment = Figment::new(); - - // Lowest-precedence secret layer: persisted HMAC secret from the config dir's - // `secrets.toml`. Read-only here (generation happens in - // `secrets::ensure()` during serve/admin). Best-effort: a missing or - // unreadable file just leaves the built-in defaults in play. Env vars - // and CLI flags still override these. - if let Ok(Some(persisted)) = secrets::load() { - figment = figment.merge(Serialized::defaults(persisted)); - } - - if let Some(path) = resolve_config_path(cli) { - figment = figment.merge(Toml::file(path)); - } - - // Environment layer: keep the existing unprefixed names. Remap the few - // that don't match a field 1:1 (legacy log vars, VCMS_ENV) and project - // log keys into the nested `log` table via the "." separator. - figment = figment.merge( - Env::raw() - .map(|key| { - let k = key.as_str().to_ascii_lowercase(); - match k.as_str() { - "rust_log" => "log.level".into(), - "log_output" => "log.output".into(), - "log_format" => "log.format".into(), - "log_annotations" => "log.annotations".into(), - "log_dir" => "log.dir".into(), - "vcms_env" => "env".into(), - _ => k.into(), - } - }) - .split("."), - ); - - // CLI flag layer: highest precedence, only for flags the user passed. - if let Some(v) = &cli.bind { - figment = figment.merge(Serialized::default("bind_address", v)); - } - if let Some(v) = &cli.database_url { - figment = figment.merge(Serialized::default("database_url", v)); - } - if let Some(v) = &cli.log_level { - figment = figment.merge(Serialized::default("log.level", v)); - } - - figment.extract::().map_err(Box::new)?.into_config() - } - - /// Convenience for callers that only want env + defaults (no CLI flags). - pub fn from_env() -> Self { - dotenvy::dotenv().ok(); - Self::load(&Cli::default()).expect("Failed to load configuration") + pub fn load( + paths: &RuntimePaths, + persisted: &secrets::PersistedSecrets, + ) -> Result> { + let bootstrap = load_bootstrap(paths)?; + validate_bootstrap(&bootstrap)?; + + Ok(Self { + database_url: persisted.database_url.clone().unwrap_or_else(|| paths.database_url()), + bind_address: bootstrap.server.http_address, + grpc_bind_address: bootstrap.server.grpc_address, + storage_fs_path: Some(paths.storage_dir().to_string_lossy().into_owned()), + s3_access_key_id: None, + s3_secret_access_key: None, + s3_bucket: None, + s3_region: None, + s3_endpoint: None, + s3_public_url: None, + backup_enabled: true, + backup_destination: "filesystem".to_owned(), + backup_local_path: Some(paths.backups_dir().to_string_lossy().into_owned()), + backup_zstd_level: 12, + backup_default_retention: 7, + backup_s3_access_key_id: None, + backup_s3_secret_access_key: None, + backup_s3_bucket: None, + backup_s3_region: None, + backup_s3_endpoint: None, + backup_s3_public_url: None, + backup_encryption_key: Some(persisted.backup_encryption_key.clone()), + max_upload_size_bytes: DEFAULT_MAX_UPLOAD_BYTES, + upload_token_expiry_secs: crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS, + cookie_secure: false, + session_lifetime_hours: 24, + public_registration_enabled: false, + allowed_origins: Vec::new(), + production: false, + db_max_connections: 10, + db_min_connections: 2, + db_acquire_timeout_secs: 30, + db_idle_timeout_secs: 600, + rate_limit_max_requests: 100, + rate_limit_window_secs: 60, + bcrypt_cost: bcrypt::DEFAULT_COST, + trust_proxy_headers: false, + webhook_allow_private_targets: false, + token_index_key: secrets::derive_key_hex(&persisted.master_key, "access-token-index"), + session_auth_key: secrets::derive_key_hex(&persisted.master_key, "dashboard-session-auth"), + signed_upload_key: secrets::derive_key_hex(&persisted.master_key, "signed-upload-auth"), + webhook_encryption_key: secrets::derive_key_hex(&persisted.master_key, "webhook-encryption"), + search_enabled: true, + search_index_path: Some(paths.search_dir().to_string_lossy().into_owned()), + mcp_enabled: true, + mcp_allowed_hosts: default_mcp_allowed_hosts(), + mcp_allowed_origins: Vec::new(), + public_url: None, + log_level: bootstrap.log.level, + log_output: bootstrap.log.output.to_string(), + log_dir: paths.logs_dir().to_string_lossy().into_owned(), + }) } pub fn has_s3(&self) -> bool { self.s3_access_key_id.is_some() && self.s3_secret_access_key.is_some() && self.s3_bucket.is_some() } - /// Whether a dedicated S3 backup destination is fully configured. pub fn has_backup_s3(&self) -> bool { self.backup_s3_access_key_id.is_some() && self.backup_s3_secret_access_key.is_some() @@ -253,477 +192,148 @@ impl Config { } pub fn validate_security(&self) -> Result<(), String> { - // The secret is always real here: config load hard-errors when no - // secret is resolved, so there is no built-in placeholder to guard - // against. We only enforce strength/cookie policy in production. - if self.production { - if self.hmac_secret.len() < 32 { - return Err("HMAC_SECRET must be at least 32 bytes long".into()); - } - if !self.cookie_secure { - return Err("COOKIE_SECURE must be enabled in production".into()); - } - } - Ok(()) } - /// Render the effective configuration as TOML with secrets redacted. - /// Used by `vcms config show`. - pub fn redacted_toml(&self) -> String { + pub fn redacted_toml(&self, paths: &RuntimePaths) -> String { + let db_kind = if self.database_url.starts_with("postgres") { + "postgres" + } else if self.database_url.starts_with("mysql") { + "mysql" + } else { + "sqlite" + }; format!( - "# Effective configuration (secrets redacted)\n\ - database_url = \"{}\"\n\ - hmac_secret = \"{}\"\n\ - bind_address = \"{}\"\n\ - grpc_bind_address = \"{}\"\n\ - public_url = {}\n\ - env = \"{}\"\n\n\ - # Storage\n\ - storage_fs_path = {}\n\ - s3_access_key_id = {}\n\ - s3_secret_access_key = {}\n\ - s3_bucket = {}\n\ - s3_region = {}\n\ - s3_endpoint = {}\n\ - s3_public_url = {}\n\n\ - # Uploads\n\ - max_upload_size_mb = {}\n\ - upload_token_expiry_secs = {}\n\n\ - # Security / sessions\n\ - cookie_secure = {}\n\ - session_lifetime_hours = {}\n\ - public_registration_enabled = {}\n\ - allowed_origins = {}\n\n\ - # Database pool\n\ - db_max_connections = {}\n\ - db_min_connections = {}\n\ - db_acquire_timeout_secs = {}\n\ - db_idle_timeout_secs = {}\n\n\ - # Rate limiting\n\ - rate_limit_max_requests = {}\n\ - rate_limit_window_secs = {}\n\ - trust_proxy_headers = {}\n\n\ - # Password hashing\n\ - bcrypt_cost = {}\n\n\ - # MCP\n\ - mcp_enabled = {}\n\ - mcp_allowed_hosts = {}\n\ - mcp_allowed_origins = {}\n\n\ - [log]\n\ - level = \"{}\"\n\ - output = \"{}\"\n\ - format = \"{}\"\n\ - annotations = {}\n\ - dir = \"{}\"\n", - self.database_url, - redact(&self.hmac_secret), + "mode = \"{}\"\nroot = \"{}\"\ndatabase = \"{} (configured)\"\n\n[secrets]\nmaster_key_present = true\nbackup_encryption_key_present = {}\ndatabase_url_present = {}\n\n[server]\nhttp_address = \"{}\"\ngrpc_address = \"{}\"\n\n[log]\nlevel = \"{}\"\noutput = \"{}\"\npath = \"{}\"\n", + paths.mode(), + paths.root().display(), + db_kind, + self.backup_encryption_key.is_some(), + !self.database_url.starts_with("sqlite://"), self.bind_address, self.grpc_bind_address, - opt_str(&self.public_url), - if self.production { "production" } else { "development" }, - opt_str(&self.storage_fs_path), - opt_secret(&self.s3_access_key_id), - opt_secret(&self.s3_secret_access_key), - opt_str(&self.s3_bucket), - opt_str(&self.s3_region), - opt_str(&self.s3_endpoint), - opt_str(&self.s3_public_url), - self.max_upload_size_bytes / (1024 * 1024), - self.upload_token_expiry_secs, - self.cookie_secure, - self.session_lifetime_hours, - self.public_registration_enabled, - toml_list(&self.allowed_origins), - self.db_max_connections, - self.db_min_connections, - self.db_acquire_timeout_secs, - self.db_idle_timeout_secs, - self.rate_limit_max_requests, - self.rate_limit_window_secs, - self.trust_proxy_headers, - self.bcrypt_cost, - self.mcp_enabled, - toml_list(&self.mcp_allowed_hosts), - toml_list(&self.mcp_allowed_origins), self.log_level, self.log_output, - self.log_format, - self.log_annotations, - self.log_dir, + paths.logs_dir().display(), ) } } -impl RawConfig { - fn into_config(self) -> Result> { - // No silent placeholder: a secret must come from secrets.toml, config, or - // the HMAC_SECRET env var. `serve`/`admin` generate one via - // `secrets::ensure()` and `mcp stdio` guards on its presence, so this only - // fires on a genuinely uninitialized instance. - let hmac_secret = self.hmac_secret.ok_or_else(|| { - Box::new(figment::Error::from(format!( - "No HMAC secret resolved; run `vcms serve` once to generate {}, or set HMAC_SECRET", - paths::secrets_file().display(), - ))) - })?; - - let log = self.log.unwrap_or_default(); - - let upload_token_expiry_secs = self - .upload_token_expiry_secs - .unwrap_or(crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS); - if upload_token_expiry_secs <= 0 { - return Err(Box::new(figment::Error::from(format!( - "upload_token_expiry_secs must be positive (got {upload_token_expiry_secs})" - )))); +impl Default for Config { + fn default() -> Self { + let paths = RuntimePaths::portable("."); + let bootstrap = BootstrapConfig::default(); + let persisted = secrets::fresh(None); + Self { + database_url: paths.database_url(), + bind_address: bootstrap.server.http_address, + grpc_bind_address: bootstrap.server.grpc_address, + storage_fs_path: Some(paths.storage_dir().to_string_lossy().into_owned()), + s3_access_key_id: None, + s3_secret_access_key: None, + s3_bucket: None, + s3_region: None, + s3_endpoint: None, + s3_public_url: None, + backup_enabled: true, + backup_destination: "filesystem".to_owned(), + backup_local_path: Some(paths.backups_dir().to_string_lossy().into_owned()), + backup_zstd_level: 12, + backup_default_retention: 7, + backup_s3_access_key_id: None, + backup_s3_secret_access_key: None, + backup_s3_bucket: None, + backup_s3_region: None, + backup_s3_endpoint: None, + backup_s3_public_url: None, + backup_encryption_key: Some(persisted.backup_encryption_key), + max_upload_size_bytes: DEFAULT_MAX_UPLOAD_BYTES, + upload_token_expiry_secs: crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS, + cookie_secure: false, + session_lifetime_hours: 24, + public_registration_enabled: false, + allowed_origins: Vec::new(), + production: false, + db_max_connections: 10, + db_min_connections: 2, + db_acquire_timeout_secs: 30, + db_idle_timeout_secs: 600, + rate_limit_max_requests: 100, + rate_limit_window_secs: 60, + bcrypt_cost: bcrypt::DEFAULT_COST, + trust_proxy_headers: false, + webhook_allow_private_targets: false, + token_index_key: secrets::derive_key_hex(&persisted.master_key, "access-token-index"), + session_auth_key: secrets::derive_key_hex(&persisted.master_key, "dashboard-session-auth"), + signed_upload_key: secrets::derive_key_hex(&persisted.master_key, "signed-upload-auth"), + webhook_encryption_key: secrets::derive_key_hex(&persisted.master_key, "webhook-encryption"), + search_enabled: true, + search_index_path: Some(paths.search_dir().to_string_lossy().into_owned()), + mcp_enabled: true, + mcp_allowed_hosts: default_mcp_allowed_hosts(), + mcp_allowed_origins: Vec::new(), + public_url: None, + log_level: bootstrap.log.level, + log_output: bootstrap.log.output.to_string(), + log_dir: paths.logs_dir().to_string_lossy().into_owned(), } - - Ok(Config { - database_url: self.database_url.unwrap_or_else(paths::default_database_url), - hmac_secret, - bind_address: self.bind_address.unwrap_or_else(|| "0.0.0.0:3000".into()), - grpc_bind_address: self.grpc_bind_address.unwrap_or_else(|| "0.0.0.0:50051".into()), - storage_fs_path: self.storage_fs_path, - s3_access_key_id: self.s3_access_key_id, - s3_secret_access_key: self.s3_secret_access_key, - s3_bucket: self.s3_bucket, - s3_region: self.s3_region, - s3_endpoint: self.s3_endpoint, - s3_public_url: self.s3_public_url, - backup_enabled: self.backup_enabled.unwrap_or(true), - backup_destination: self.backup_destination.unwrap_or_else(|| "filesystem".into()), - backup_local_path: self.backup_local_path, - backup_zstd_level: self.backup_zstd_level.unwrap_or(12), - backup_default_retention: self.backup_default_retention.unwrap_or(7), - backup_s3_access_key_id: self.backup_s3_access_key_id, - backup_s3_secret_access_key: self.backup_s3_secret_access_key, - backup_s3_bucket: self.backup_s3_bucket, - backup_s3_region: self.backup_s3_region, - backup_s3_endpoint: self.backup_s3_endpoint, - backup_s3_public_url: self.backup_s3_public_url, - backup_encryption_key: self.backup_encryption_key, - max_upload_size_bytes: self.max_upload_size_mb.unwrap_or(50) * 1024 * 1024, - upload_token_expiry_secs, - cookie_secure: self.cookie_secure.unwrap_or(false), - session_lifetime_hours: self.session_lifetime_hours.unwrap_or(24), - public_registration_enabled: self.public_registration_enabled.unwrap_or(false), - allowed_origins: self.allowed_origins.unwrap_or_default(), - production: self.env.map(|v| v.eq_ignore_ascii_case("production")).unwrap_or(false), - db_max_connections: self.db_max_connections.unwrap_or(20), - db_min_connections: self.db_min_connections.unwrap_or(2), - db_acquire_timeout_secs: self.db_acquire_timeout_secs.unwrap_or(30), - db_idle_timeout_secs: self.db_idle_timeout_secs.unwrap_or(600), - rate_limit_max_requests: self.rate_limit_max_requests.unwrap_or(100), - rate_limit_window_secs: self.rate_limit_window_secs.unwrap_or(60), - bcrypt_cost: self.bcrypt_cost.unwrap_or(bcrypt::DEFAULT_COST), - trust_proxy_headers: self.trust_proxy_headers.unwrap_or(false), - webhook_allow_private_targets: self.webhook_allow_private_targets.unwrap_or(false), - search_enabled: self.search_enabled.unwrap_or(true), - search_index_path: self.search_index_path, - mcp_enabled: self.mcp_enabled.unwrap_or(true), - mcp_allowed_hosts: self.mcp_allowed_hosts.unwrap_or_else(default_mcp_allowed_hosts), - mcp_allowed_origins: self.mcp_allowed_origins.unwrap_or_default(), - public_url: self.public_url.map(|v| v.trim_end_matches('/').to_string()), - log_level: log.level.unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string()), - log_output: log.output.unwrap_or_else(|| "stdout".into()), - log_format: log.format.unwrap_or_else(|| "pretty".into()), - log_annotations: log.annotations.unwrap_or(false), - log_dir: log - .dir - .unwrap_or_else(|| paths::logs_dir().to_string_lossy().into_owned()), - }) } } -/// Resolve the config file path. First match wins; a missing file is fine. -/// Order: `--config` / `VCMS_CONFIG` > `./vcms.toml` > user config dir > `/etc/vcms`. -pub fn resolve_config_path(cli: &Cli) -> Option { - if let Some(path) = &cli.config { - return Some(path.clone()); +pub fn ensure_bootstrap(paths: &RuntimePaths) -> Result<(), Box> { + let path = paths.config_file(); + if path.exists() { + load_bootstrap(paths)?; + return Ok(()); } - config_search_paths().into_iter().find(|candidate| candidate.exists()) + let body = toml::to_string_pretty(&BootstrapConfig::default())?; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?; + file.write_all(body.as_bytes())?; + file.sync_all()?; + Ok(()) } -/// The ordered list of locations searched when no `--config` is given. -pub fn config_search_paths() -> Vec { - let mut paths = vec![PathBuf::from("vcms.toml")]; - if let Some(path) = user_config_path() { - paths.push(path); - } - paths.push(PathBuf::from("/etc/vcms/config.toml")); - paths -} - -/// The user-config location for the config file: the platform config dir -/// (`config.toml`), or `$VCMS_HOME/config.toml` in single-dir mode. -pub fn user_config_path() -> Option { - Some(paths::config_file()) +pub fn load_bootstrap(paths: &RuntimePaths) -> Result> { + let raw = std::fs::read_to_string(paths.config_file())?; + let config: BootstrapConfig = toml::from_str(&raw)?; + validate_bootstrap(&config)?; + Ok(config) } -/// The scaffold written by `vcms config init` — non-secret keys only, with -/// secrets shown as commented env-var hints. -pub fn default_config_toml() -> String { - let hosts = default_mcp_allowed_hosts() - .iter() - .map(|h| format!("\"{h}\"")) - .collect::>() - .join(", "); - format!( - "# CMS configuration. Non-secret settings live here; secrets stay in the\n\ - # environment (or a .env file). Precedence: CLI flag > env var > this file.\n\ - #\n\ - # Secrets are NOT read from this file by convention — set them via env:\n\ - # DATABASE_URL, HMAC_SECRET,\n\ - # S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY\n\n\ - bind_address = \"0.0.0.0:3000\"\n\ - grpc_bind_address = \"0.0.0.0:50051\"\n\ - # public_url = \"https://cms.example.com\"\n\ - # env = \"production\"\n\n\ - # --- Storage (filesystem) ---\n\ - # storage_fs_path = \"./storage\"\n\n\ - # --- Storage (S3, non-secret parts) ---\n\ - # s3_bucket = \"my-bucket\"\n\ - # s3_region = \"us-east-1\"\n\ - # s3_endpoint = \"https://s3.example.com\"\n\ - # s3_public_url = \"https://cdn.example.com\"\n\n\ - # --- Uploads ---\n\ - max_upload_size_mb = 50\n\ - # Signed upload URL lifetime (seconds)\n\ - upload_token_expiry_secs = {default_upload_expiry}\n\n\ - # --- Security / sessions ---\n\ - cookie_secure = false\n\ - session_lifetime_hours = 24\n\ - public_registration_enabled = false\n\ - allowed_origins = []\n\n\ - # --- Database pool ---\n\ - db_max_connections = 10\n\ - db_min_connections = 2\n\ - db_acquire_timeout_secs = 30\n\ - db_idle_timeout_secs = 600\n\n\ - # --- Rate limiting ---\n\ - rate_limit_max_requests = 100\n\ - rate_limit_window_secs = 60\n\ - # Trust X-Forwarded-For / X-Real-IP for client IP (enable only behind a trusted proxy)\n\ - trust_proxy_headers = false\n\n\ - # --- Password hashing ---\n\ - bcrypt_cost = 12\n\n\ - # --- Webhooks ---\n\ - # Allow webhooks to target private/loopback addresses (SSRF guard off)\n\ - webhook_allow_private_targets = false\n\n\ - # --- Backups ---\n\ - # Run the scheduled-backup poller and allow on-demand backups.\n\ - backup_enabled = true\n\ - # Destination for backup artifacts: \"filesystem\" (default, the data dir's backups/)\n\ - # or \"s3\". S3 credentials are secrets — set them via env (see below).\n\ - backup_destination = \"filesystem\"\n\ - # backup_local_path = \"./backups\"\n\ - backup_zstd_level = 12\n\ - backup_default_retention = 7\n\ - # S3 backup destination (non-secret parts; keep it in a SEPARATE account/bucket\n\ - # from your site-files S3 for blast-radius isolation):\n\ - # backup_s3_bucket = \"my-cms-backups\"\n\ - # backup_s3_region = \"us-east-1\"\n\ - # backup_s3_endpoint = \"https://s3.example.com\"\n\ - # Secrets via env: BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEY,\n\ - # and BACKUP_ENCRYPTION_KEY (else auto-generated into secrets.toml).\n\n\ - # --- Full-text search (Tantivy) ---\n\ - # Build a local inverted index so entry search is ranked + tokenized.\n\ - # When false, search falls back to a basic SQL LIKE match.\n\ - search_enabled = true\n\ - # search_index_path = \"./search\" # defaults to the cache dir's search/\n\n\ - # --- MCP ---\n\ - mcp_enabled = true\n\ - mcp_allowed_hosts = [{hosts}]\n\ - mcp_allowed_origins = []\n\n\ - # --- Logging ---\n\ - [log]\n\ - level = \"{DEFAULT_LOG_LEVEL}\"\n\ - output = \"stdout\" # stdout | file\n\ - format = \"pretty\" # pretty | json\n\ - annotations = false # include file + line numbers\n\ - dir = \"logs\" # used when output = \"file\"\n", - default_upload_expiry = crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS, - ) +fn validate_bootstrap(config: &BootstrapConfig) -> Result<(), Box> { + config + .server + .http_address + .parse::() + .map_err(|error| format!("server.http_address is invalid: {error}"))?; + config + .server + .grpc_address + .parse::() + .map_err(|error| format!("server.grpc_address is invalid: {error}"))?; + tracing_subscriber::EnvFilter::try_new(&config.log.level) + .map_err(|error| format!("log.level is invalid: {error}"))?; + Ok(()) } fn default_mcp_allowed_hosts() -> Vec { vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), - "[::1]".to_string(), - "localhost:3000".to_string(), - "127.0.0.1:3000".to_string(), - "[::1]:3000".to_string(), + "localhost".to_owned(), + "127.0.0.1".to_owned(), + "[::1]".to_owned(), + "localhost:3000".to_owned(), + "127.0.0.1:3000".to_owned(), + "[::1]:3000".to_owned(), ] } -/// Accepts booleans as `true`/`false`, integers (`0`/`1`), or strings -/// (`"true"`/`"1"`) for env back-compat. -fn de_opt_lenient_bool<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum Lenient { - Bool(bool), - Int(i64), - Str(String), - } - Ok(match Option::::deserialize(deserializer)? { - None => None, - Some(Lenient::Bool(b)) => Some(b), - Some(Lenient::Int(i)) => Some(i != 0), - Some(Lenient::Str(s)) => Some(matches!(s.to_ascii_lowercase().as_str(), "1" | "true")), - }) -} - -/// Accepts a list either as a TOML array or as a comma-separated string -/// (env back-compat with the previous CSV parsing). -fn de_opt_str_list<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum List { - Vec(Vec), - Str(String), - } - Ok(match Option::::deserialize(deserializer)? { - None => None, - Some(List::Vec(v)) => Some(v), - Some(List::Str(s)) => Some( - s.split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .collect(), - ), - }) -} - -fn redact(secret: &str) -> String { - if secret.is_empty() { - "".into() - } else { - "***redacted***".into() - } -} - -fn opt_str(value: &Option) -> String { - match value { - Some(v) => format!("\"{v}\""), - None => "\"\"".to_string(), - } -} - -fn opt_secret(value: &Option) -> String { - match value { - Some(_) => "\"***redacted***\"".to_string(), - None => "\"\"".to_string(), - } -} - -fn toml_list(values: &[String]) -> String { - let inner = values.iter().map(|v| format!("\"{v}\"")).collect::>().join(", "); - format!("[{inner}]") -} - #[cfg(test)] mod tests { use super::*; - fn s3_config() -> Config { - Config { - s3_access_key_id: Some("key".to_string()), - s3_secret_access_key: Some("secret".to_string()), - s3_bucket: Some("bucket".to_string()), - s3_region: Some("us-east-1".to_string()), - ..Config::default() - } - } - #[test] - fn test_has_s3_returns_true_when_all_s3_fields_are_set() { - assert!(s3_config().has_s3()); - } - - #[test] - fn test_has_s3_returns_false_when_access_key_id_is_missing() { - let config = Config { - s3_access_key_id: None, - ..s3_config() - }; - assert!(!config.has_s3()); - } - - #[test] - fn test_has_s3_returns_false_when_secret_is_missing() { - let config = Config { - s3_secret_access_key: None, - ..s3_config() - }; - assert!(!config.has_s3()); - } - - #[test] - fn test_has_s3_returns_false_when_bucket_is_missing() { - let config = Config { - s3_bucket: None, - ..s3_config() - }; - assert!(!config.has_s3()); - } - - #[test] - fn test_config_default_values() { - let config = Config { - storage_fs_path: Some("/tmp/storage".to_string()), - max_upload_size_bytes: 50 * 1024 * 1024, - cookie_secure: true, - ..Config::default() - }; - - assert!(!config.has_s3()); - assert_eq!(config.max_upload_size_bytes, 50 * 1024 * 1024); - assert!(config.cookie_secure); - } - - #[test] - fn test_raw_config_into_config_applies_defaults() { - let config = RawConfig { - hmac_secret: Some("test-secret".to_string()), - ..RawConfig::default() - } - .into_config() - .expect("a supplied secret should produce a config"); - assert!(config.database_url.starts_with("sqlite://")); - assert!(config.database_url.ends_with("vcms.db")); - assert_eq!(config.bind_address, "0.0.0.0:3000"); - assert_eq!(config.max_upload_size_bytes, 50 * 1024 * 1024); - assert_eq!(config.log_level, DEFAULT_LOG_LEVEL); - assert_eq!(config.log_output, "stdout"); - assert!(config.mcp_enabled); - assert!(!config.production); - } - - #[test] - fn test_into_config_requires_hmac_secret() { - // No secret from any layer => hard error, never a silent placeholder. - assert!(RawConfig::default().into_config().is_err()); - } - - #[test] - fn test_env_maps_to_production() { - let config = RawConfig { - hmac_secret: Some("test-secret".to_string()), - env: Some("production".to_string()), - ..RawConfig::default() - } - .into_config() - .expect("a supplied secret should produce a config"); - assert!(config.production); + fn bootstrap_rejects_unknown_fields() { + let raw = "[server]\nhttp_address='127.0.0.1:3000'\ngrpc_address='127.0.0.1:50051'\nextra=true\n[log]\nlevel='info'\noutput='file'\n"; + assert!(toml::from_str::(raw).is_err()); } } diff --git a/apps/backend/src/diagnostics.rs b/apps/backend/src/diagnostics.rs index c1b2c1ad..d91cf1c5 100644 --- a/apps/backend/src/diagnostics.rs +++ b/apps/backend/src/diagnostics.rs @@ -1,74 +1,70 @@ use std::net::SocketAddr; -use std::path::Path; -use crate::cli::Cli; -use crate::config::{self, Config}; use crate::database::connect_db_without_migrations; -pub async fn run(cli: &Cli) -> Result<(), Box> { +pub async fn run() -> Result<(), Box> { let mut failed = false; - let config_path = config::resolve_config_path(cli); - let loaded = Config::load(cli).map_err(|error| format!("configuration invalid: {error}"))?; + let context = crate::runtime::RuntimeContext::initialize_default()?; + let config = &context.bootstrap; + + report("mode", true, context.mode.to_string(), &mut failed); + report( + "root", + context.paths.root().is_dir(), + context.paths.root().display().to_string(), + &mut failed, + ); report( "config", - config_path.as_deref().is_none_or(Path::is_file), - config_path - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "built-in defaults (run `vcms config init`)".to_owned()), + context.paths.config_file().is_file(), + context.paths.config_file().display().to_string(), + &mut failed, + ); + report( + "secrets", + context.paths.secrets_file().is_file(), + "present; values redacted".to_owned(), + &mut failed, + ); + report( + "root-permissions", + crate::paths::permissions_secure(context.paths.root(), 0o700), + "private root expected".to_owned(), &mut failed, ); + report( + "secret-permissions", + crate::paths::permissions_secure(&context.paths.secrets_file(), 0o600), + "private secrets expected".to_owned(), + &mut failed, + ); + for (name, path) in [ + ("storage-dir", context.paths.storage_dir()), + ("backup-dir", context.paths.backups_dir()), + ("log-dir", context.paths.logs_dir()), + ("search-dir", context.paths.search_dir()), + ] { + report(name, path.is_dir(), path.display().to_string(), &mut failed); + } - let directories = [ - ( - "config-dir", - crate::paths::config_file().parent().map(Path::to_path_buf), + match connect_db_without_migrations(config).await { + Ok(pool) => report( + "database", + pool.ping().await.is_ok(), + "reachable".to_owned(), + &mut failed, ), - ( - "data-dir", - crate::paths::default_db_path().parent().map(Path::to_path_buf), + Err(_) if config.database_url.starts_with("sqlite:") && !context.paths.database_file().exists() => report( + "database", + true, + "not initialized; first serve will create it".to_owned(), + &mut failed, ), - ("storage-dir", Some(crate::paths::storage_dir())), - ]; - for (name, path) in directories { - if let Some(path) = path { - let result = std::fs::metadata(&path); - let ok = result - .as_ref() - .is_ok_and(|metadata| metadata.is_dir() && !metadata.permissions().readonly()); - report(name, ok, path.display().to_string(), &mut failed); - } - } - - match connect_db_without_migrations(&loaded).await { - Ok(pool) => { - report( - "database", - pool.ping().await.is_ok(), - "reachable; schema current".to_owned(), - &mut failed, - ); - } - Err(_) if loaded.database_url.starts_with("sqlite:") && !crate::paths::default_db_path().exists() => { - report( - "database", - true, - "not initialized; first service start will create and migrate it".to_owned(), - &mut failed, - ); - } Err(error) => report("database", false, sanitize(&error.to_string()), &mut failed), } - check_bind("rest-bind", &loaded.bind_address, &mut failed).await; - check_bind("grpc-bind", &loaded.grpc_bind_address, &mut failed).await; - report( - "service-identity", - true, - std::env::var("USER") - .or_else(|_| std::env::var("USERNAME")) - .unwrap_or_else(|_| "unknown".to_owned()), - &mut failed, - ); + check_bind("rest-bind", &config.bind_address, &mut failed).await; + check_bind("grpc-bind", &config.grpc_bind_address, &mut failed).await; + if failed { Err("doctor found one or more failures".into()) } else { @@ -97,7 +93,8 @@ fn report(name: &str, ok: bool, detail: String, failed: &mut bool) { fn sanitize(message: &str) -> String { if message.len() > 240 { - format!("{}…", &message[..240]) + let end = message.floor_char_boundary(240); + format!("{}…", &message[..end]) } else { message.to_owned() } diff --git a/apps/backend/src/grpc/auth.rs b/apps/backend/src/grpc/auth.rs index 8678a470..842b2621 100644 --- a/apps/backend/src/grpc/auth.rs +++ b/apps/backend/src/grpc/auth.rs @@ -23,7 +23,7 @@ pub fn parse_token(token: &str, config: &Config) -> Result { + Ok(report) => { // The search index is derived data excluded from backups, so rebuild // the affected scope after a restore to reflect the restored content. if let Some(search) = search { @@ -241,7 +241,7 @@ async fn run_restore( tracing::warn!("Post-restore search reindex failed: {}", e); } } - StatusCode::NO_CONTENT.into_response() + (StatusCode::OK, Json(report)).into_response() } Err(e) => err_response(e), } diff --git a/apps/backend/src/handlers/file_handler.rs b/apps/backend/src/handlers/file_handler.rs index 83533579..2ec68c6c 100644 --- a/apps/backend/src/handlers/file_handler.rs +++ b/apps/backend/src/handlers/file_handler.rs @@ -25,6 +25,7 @@ use crate::repository::Repository; use crate::repository::traits::ListFilesParams; use crate::services::Services; use crate::services::file::StreamingUploadRequest; +use crate::services::settings::SettingsService; use crate::signed_upload::{SignedUploadError, SignedUploadToken}; use crate::storage::{StorageProvider, StorageRegistry}; @@ -125,12 +126,13 @@ pub async fn list_files( security(("bearer" = []), ("access_token" = [])), tag = "files" )] -#[instrument(skip(repository, services, ctx, multipart, storage_registry))] +#[instrument(skip(repository, services, settings, ctx, multipart, storage_registry))] pub async fn upload_file( ctx: RequestContext, Extension(repository): Extension, Extension(services): Extension, Extension(storage_registry): Extension>, + Extension(settings): Extension, mut multipart: Multipart, ) -> Response { if let Err((status, err)) = require_site_action(&ctx, &repository, Action::FilesWrite).await { @@ -149,6 +151,7 @@ pub async fn upload_file( Err(status) => return (status, Json(json!({"error": "Storage not configured"}))).into_response(), }; let created_by = ctx.auth.actor.user_id().map(String::from); + let max_bytes = settings.current().general.upload_limit_mb * 1024 * 1024; // Stream the "file" field straight into storage (constant memory); size cap // and content sniffing are enforced by the service while streaming. @@ -184,6 +187,7 @@ pub async fn upload_file( created_by: created_by.as_deref(), storage: storage.clone(), storage_provider: &storage_provider, + max_bytes, }, stream, ) @@ -213,17 +217,18 @@ pub async fn upload_file( ), tag = "files" )] -#[instrument(skip(services, config, storage_registry, headers, body))] +#[instrument(skip(services, config, settings, storage_registry, headers, body))] pub async fn upload_via_signed_url( Path(token): Path, headers: HeaderMap, Extension(services): Extension, Extension(config): Extension, Extension(storage_registry): Extension>, + Extension(settings): Extension, body: Body, ) -> Response { // The token is the auth: HMAC-signed by the server, time-limited, single-use. - let token = match SignedUploadToken::verify(&token, &config.hmac_secret) { + let token = match SignedUploadToken::verify(&token, &config.signed_upload_key) { Ok(t) => t, Err(SignedUploadError::Expired) => { return (StatusCode::GONE, Json(json!({"error": "Upload URL expired"}))).into_response(); @@ -234,16 +239,17 @@ pub async fn upload_via_signed_url( }; // Fast reject when the client announces an oversized body upfront. + let max_bytes = settings.current().general.upload_limit_mb * 1024 * 1024; if let Some(len) = headers .get(header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) - && len > config.max_upload_size_bytes as u64 + && len > max_bytes as u64 { return ( StatusCode::PAYLOAD_TOO_LARGE, Json(json!({ - "error": format!("File too large. Maximum size is {}MB", config.max_upload_size_bytes / (1024 * 1024)) + "error": format!("File too large. Maximum size is {}MB", max_bytes / (1024 * 1024)) })), ) .into_response(); @@ -286,6 +292,7 @@ pub async fn upload_via_signed_url( created_by: None, storage, storage_provider: &token.storage_provider, + max_bytes, }, stream, ) diff --git a/apps/backend/src/handlers/settings_handler.rs b/apps/backend/src/handlers/settings_handler.rs new file mode 100644 index 00000000..60a16fba --- /dev/null +++ b/apps/backend/src/handlers/settings_handler.rs @@ -0,0 +1,378 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::Extension, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::config::Config; +use crate::database::pool::DbPool; +use crate::error::AppError; +use crate::middleware::auth::{AuthContext, require_instance_action}; +use crate::models::authorization::Action; +use crate::repository::Repository; +use crate::services::backup::{BackupDestination, BackupService}; +use crate::services::settings::{ + BackupSettings, CredentialPair, EncryptedCredentials, GeneralSettings, InstanceSettings, SecuritySettings, + SettingsService, StorageSettings, +}; +use crate::storage::{S3Storage, StorageRegistry}; + +#[derive(Serialize)] +pub struct SettingsResponse { + #[serde(flatten)] + settings: InstanceSettings, + storage_credentials: CredentialState, + backup_credentials: CredentialState, +} + +#[derive(Serialize)] +pub struct CredentialState { + configured: bool, + masked_access_key_id: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorageUpdate { + #[serde(flatten)] + settings: StorageSettings, + access_key_id: Option, + secret_access_key: Option, + #[serde(default)] + confirm_target_change: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackupUpdate { + #[serde(flatten)] + settings: BackupSettings, + access_key_id: Option, + secret_access_key: Option, + #[serde(default)] + confirm_target_change: bool, +} + +async fn owner(auth: &AuthContext, repository: &Repository) -> Result<(), Response> { + require_instance_action(auth, repository, Action::InstanceSettings) + .await + .map(|_| ()) + .map_err(|error| error.into_response()) +} + +pub async fn get_settings( + auth: AuthContext, + Extension(repository): Extension, + Extension(settings): Extension, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + response(&settings).await.into_response() +} + +pub async fn update_general( + auth: AuthContext, + Extension(repository): Extension, + Extension(settings): Extension, + Json(section): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let current = settings.current(); + let credentials = settings.credentials().await; + let mut next = (*current).clone(); + next.general = section; + publish(&settings, ¤t, &credentials, next, credentials.clone()).await +} + +pub async fn update_security( + auth: AuthContext, + Extension(repository): Extension, + Extension(settings): Extension, + Json(section): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let current = settings.current(); + let credentials = settings.credentials().await; + let mut next = (*current).clone(); + next.security = section; + publish(&settings, ¤t, &credentials, next, credentials.clone()).await +} + +pub async fn update_storage( + auth: AuthContext, + Extension(repository): Extension, + Extension(pool): Extension, + Extension(settings): Extension, + Extension(registry): Extension>, + Json(update): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let current = settings.current(); + if target_changed(¤t.storage, &update.settings) && !update.confirm_target_change { + return AppError::Conflict("Changing the S3 bucket or endpoint requires confirm_target_change=true".into()) + .into_response(); + } + if update.settings.provider == "filesystem" && current.storage.provider == "s3" { + match s3_site_count(&pool).await { + Ok(count) if count > 0 => { + return AppError::Conflict(format!("{count} site(s) still use S3 storage")).into_response(); + } + Err(error) => return AppError::Internal(error.to_string()).into_response(), + _ => {} + } + } + let current_credentials = settings.credentials().await; + let mut credentials = current_credentials.clone(); + let provider: Option = if update.settings.provider == "filesystem" { + credentials.storage = None; + None + } else { + match merged_pair( + credentials.storage.clone(), + update.access_key_id, + update.secret_access_key, + ) { + Ok(pair) => { + let provider = match probe_s3(&update.settings, &pair).await { + Ok(provider) => provider, + Err(error) => return AppError::BadRequest(format!("S3 probe failed: {error}")).into_response(), + }; + credentials.storage = Some(pair); + Some(provider) + } + Err(error) => return AppError::BadRequest(error).into_response(), + } + }; + let mut next = (*current).clone(); + next.storage = update.settings; + if settings.current().storage != current.storage { + return AppError::Conflict("Storage settings changed while the S3 probe was running; review and retry".into()) + .into_response(); + } + if let Err(error) = settings + .publish(¤t, ¤t_credentials, next, credentials) + .await + { + return AppError::BadRequest(error).into_response(); + } + match provider { + Some(provider) => registry.register("s3", Arc::new(provider)), + None => registry.remove("s3"), + } + response(&settings).await.into_response() +} + +pub async fn update_backups( + auth: AuthContext, + Extension(repository): Extension, + Extension(config): Extension, + Extension(settings): Extension, + Extension(backup): Extension>, + Json(update): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let current = settings.current(); + if backup_target_changed(¤t.backups, &update.settings) && !update.confirm_target_change { + return AppError::Conflict( + "Changing the backup S3 bucket or endpoint requires confirm_target_change=true".into(), + ) + .into_response(); + } + let current_credentials = settings.credentials().await; + let mut credentials = current_credentials.clone(); + let destination = if update.settings.destination == "filesystem" { + credentials.backups = None; + match BackupDestination::filesystem( + config + .backup_local_path + .clone() + .unwrap_or_else(|| "vcms_data/backups".into()), + ) { + Ok(destination) => destination, + Err(error) => return AppError::Internal(error.to_string()).into_response(), + } + } else { + match merged_pair( + credentials.backups.clone(), + update.access_key_id, + update.secret_access_key, + ) { + Ok(pair) => { + let storage = StorageSettings { + provider: "s3".into(), + bucket: update.settings.bucket.clone(), + region: update.settings.region.clone(), + endpoint: update.settings.endpoint.clone(), + public_url: None, + }; + let provider = match probe_s3(&storage, &pair).await { + Ok(provider) => provider, + Err(error) => return AppError::BadRequest(format!("S3 probe failed: {error}")).into_response(), + }; + let destination = BackupDestination::s3( + Arc::new(provider), + pair.access_key_id.clone(), + pair.secret_access_key.clone(), + storage.bucket.clone().unwrap_or_default(), + storage.region.clone().unwrap_or_else(|| "us-east-1".into()), + storage.endpoint.clone(), + storage.public_url.clone(), + ); + credentials.backups = Some(pair); + destination + } + Err(error) => return AppError::BadRequest(error).into_response(), + } + }; + let mut next = (*current).clone(); + next.backups = update.settings; + if settings.current().backups != current.backups { + return AppError::Conflict("Backup settings changed while the S3 probe was running; review and retry".into()) + .into_response(); + } + if let Err(error) = settings + .publish(¤t, ¤t_credentials, next, credentials) + .await + { + return AppError::BadRequest(error).into_response(); + } + backup.set_destination(destination); + response(&settings).await.into_response() +} + +async fn publish( + settings: &SettingsService, + expected: &InstanceSettings, + expected_credentials: &EncryptedCredentials, + next: InstanceSettings, + credentials: EncryptedCredentials, +) -> Response { + match settings + .publish(expected, expected_credentials, next, credentials) + .await + { + Ok(()) => response(settings).await.into_response(), + Err(error) => AppError::BadRequest(error).into_response(), + } +} + +async fn response(settings: &SettingsService) -> Json { + let credentials = settings.credentials().await; + Json(SettingsResponse { + settings: (*settings.current()).clone(), + storage_credentials: credential_state(credentials.storage.as_ref()), + backup_credentials: credential_state(credentials.backups.as_ref()), + }) +} + +fn credential_state(pair: Option<&CredentialPair>) -> CredentialState { + CredentialState { + configured: pair.is_some(), + masked_access_key_id: pair.map(|pair| { + let tail: String = pair + .access_key_id + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect(); + format!("••••{tail}") + }), + } +} + +fn merged_pair( + current: Option, + id: Option, + secret: Option, +) -> Result { + match (id, secret) { + (Some(access_key_id), Some(secret_access_key)) + if !access_key_id.is_empty() && !secret_access_key.is_empty() => + { + Ok(CredentialPair { + access_key_id, + secret_access_key, + }) + } + (None, None) => current.ok_or_else(|| "S3 credentials are required".into()), + _ => Err("provide both access_key_id and secret_access_key, or omit both".into()), + } +} + +async fn probe_s3(settings: &StorageSettings, credentials: &CredentialPair) -> Result { + let storage = S3Storage::new( + &credentials.access_key_id, + &credentials.secret_access_key, + settings.bucket.as_deref().ok_or("bucket is required")?, + settings.region.as_deref().unwrap_or("us-east-1"), + settings.endpoint.as_deref(), + settings.public_url.as_deref(), + ) + .map_err(|error| error.to_string())?; + let key = format!(".vcms-probe/{}", Uuid::new_v4()); + storage + .put(&key, Bytes::from_static(b"vcms"), "application/octet-stream") + .await + .map_err(|error| error.to_string())?; + let read = storage.get(&key).await.map_err(|error| error.to_string()); + let cleanup = storage.delete(&key).await.map_err(|error| error.to_string()); + let read = read?; + cleanup?; + if read.as_ref() != b"vcms" { + return Err("probe read returned unexpected content".into()); + } + Ok(storage) +} + +fn target_changed(old: &StorageSettings, new: &StorageSettings) -> bool { + (old.provider == "s3" || new.provider == "s3") + && (old.provider != new.provider + || old.bucket != new.bucket + || old.region != new.region + || old.endpoint != new.endpoint) +} + +fn backup_target_changed(old: &BackupSettings, new: &BackupSettings) -> bool { + (old.destination == "s3" || new.destination == "s3") + && (old.destination != new.destination + || old.bucket != new.bucket + || old.region != new.region + || old.endpoint != new.endpoint) +} + +async fn s3_site_count(pool: &DbPool) -> Result { + match pool { + DbPool::Postgres(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await + } + DbPool::MySql(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await + } + DbPool::Sqlite(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await + } + } +} diff --git a/apps/backend/src/lib.rs b/apps/backend/src/lib.rs index 58333589..a64b08c3 100644 --- a/apps/backend/src/lib.rs +++ b/apps/backend/src/lib.rs @@ -43,6 +43,7 @@ pub mod handlers { pub mod entry_handler; pub mod file_handler; pub mod instance_handler; + pub mod settings_handler; pub mod singleton_handler; pub mod site_handler; pub mod webhook_handler; @@ -50,6 +51,7 @@ pub mod handlers { pub mod grpc; pub mod repository; pub mod router; +pub mod runtime; pub mod services; pub mod storage; pub mod utils; diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index ea85f893..66662c14 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -1,133 +1,194 @@ -use clap::Parser; -use cms::cli::{AdminAction, BackupAction, Cli, Command, ConfigAction, McpTransport}; -use cms::config::{self, Config}; +use std::error::Error; + +use clap::{CommandFactory, Parser}; +use cms::cli::{AdminAction, BackupAction, Cli, Command, ConfigAction, McpTransport, SecretsAction}; use cms::database::init_db_with_config; use cms::repository::Repository; - -use std::error::Error; use tracing::info; -#[tokio::main] -async fn main() { - // Load env from the cwd `.env` (dev) first, then `$VCMS_HOME/.env`. dotenvy is - // first-wins, so real env vars and the cwd file keep precedence over the home file. - // VCMS_HOME must be a real env var — resolved here before the `.env` loads — never - // set inside that file (chicken-and-egg). - dotenvy::dotenv().ok(); - let env_file = cms::paths::env_file(); - match dotenvy::from_path(&env_file) { - Ok(()) => {} - Err(e) if e.not_found() => {} // missing file is fine - // The home `.env` is optional. If VCMS_HOME points at a service-owned directory, - // a non-elevated CLI may not read it — most notably `vcms mcp stdio`, a proxy - // that needs no home file. Treat "permission denied" like "absent" and move on: - // data-touching commands still hit `paths::ensure`'s preflight. - Err(dotenvy::Error::Io(io)) if io.kind() == std::io::ErrorKind::PermissionDenied => {} - Err(e) => { - eprintln!("Error: cannot load {}: {e}", env_file.display()); +fn main() { + let cli = Cli::parse(); + if cli.command.is_none() { + let _ = Cli::command().print_help(); + println!(); + return; + } + + #[cfg(windows)] + if let Some(Command::Service { + action: cms::cli::ServiceAction::Run, + }) = &cli.command + { + if let Err(error) = cms::service::run_service_sync( + match &cli.command { + Some(Command::Service { action }) => action, + _ => unreachable!(), + }, + &cli, + ) { + eprintln!("Error: {error}"); std::process::exit(1); } + return; } - let cli = Cli::parse(); + let result = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| -> Box { Box::new(error) }) + .and_then(|runtime| runtime.block_on(dispatch(cli))); + if let Err(error) = result { + eprintln!("Error: {error}"); + std::process::exit(1); + } +} - let result = match &cli.command { - Some(Command::Config { action }) => run_config(action, &cli), - Some(Command::Admin { action }) => run_admin(action, &cli).await, - Some(Command::Backup { action }) => run_backup(action, &cli).await, +async fn dispatch(cli: Cli) -> Result<(), Box> { + match &cli.command { + Some(Command::Config { action }) => run_config(action).await, + Some(Command::Secrets { action }) => run_secrets(action).await, + Some(Command::Admin { action }) => run_admin(action).await, + Some(Command::Backup { action }) => run_backup(action).await, Some(Command::Restore { file, scope, site, import_as_new, yes, - }) => run_restore(file, scope, site, *import_as_new, *yes, &cli).await, + }) => run_restore(file, scope, site, *import_as_new, *yes).await, Some(Command::Service { action }) => cms::service::run_service(action, &cli).await, - Some(Command::Doctor) => cms::diagnostics::run(&cli).await, + Some(Command::Doctor) => cms::diagnostics::run().await, Some(Command::Mcp { transport: McpTransport::Stdio, }) => run_mcp_stdio().await, - Some(Command::Serve) | None => cms::server::run(&cli, cms::server::shutdown_signal(), || {}).await, - }; - - if let Err(e) = result { - eprintln!("Error: {e}"); - std::process::exit(1); + Some(Command::Serve) => { + if cms::service::is_installed()? { + return Err("native vcms service is installed; portable `vcms serve` is disabled".into()); + } + let context = cms::runtime::RuntimeContext::initialize(cms::paths::RuntimeMode::Portable)?; + cms::server::run(context, cms::server::shutdown_signal(), || {}).await + } + None => Ok(()), } } async fn run_mcp_stdio() -> Result<(), Box> { - // A thin proxy to the running server's `/mcp` endpoint — it touches no disk - // (no home dir, database, secrets, or search index), so it works even when those - // belong to the privileged service account. It needs only a server URL and a - // `vcms_site_*` access token, forwarded as the bearer credential. cms::tracing::init_proxy_tracing(); - let token = std::env::var("VCMS_MCP_TOKEN").map_err(|_| "VCMS_MCP_TOKEN is required for `vcms mcp stdio`")?; - let token = token.trim().to_string(); + let token = token.trim().to_owned(); if token.is_empty() { return Err("VCMS_MCP_TOKEN must not be empty".into()); } - - let base = std::env::var("VCMS_MCP_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()); + let base = std::env::var("VCMS_MCP_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".to_owned()); let endpoint = format!("{}/mcp", base.trim_end_matches('/')); - info!(%endpoint, "Starting MCP stdio proxy"); cms::mcp::transports::stdio::serve(endpoint, token).await } -fn run_config(action: &ConfigAction, cli: &Cli) -> Result<(), Box> { +async fn run_config(action: &ConfigAction) -> Result<(), Box> { match action { - ConfigAction::Init { force, path } => { - let target = match path { - Some(p) => p.clone(), - None => config::user_config_path().ok_or("Could not determine user config directory")?, - }; - if target.exists() && !force { - return Err(format!("{} already exists (use --force to overwrite)", target.display()).into()); - } - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&target, config::default_config_toml())?; - println!("Wrote default config to {}", target.display()); - } ConfigAction::Show => { - let config = Config::load(cli)?; - print!("{}", config.redacted_toml()); + let context = cms::runtime::RuntimeContext::initialize_default()?; + print!("{}", context.bootstrap.redacted_toml(&context.paths)); + match cms::database::connect_db_without_migrations(&context.bootstrap).await { + Ok(pool) => { + match cms::services::settings::SettingsService::load(pool, &context.secrets.master_key).await { + Ok(settings) => println!( + "\n[instance_settings]\n{}", + serde_json::to_string_pretty(settings.current().as_ref())? + ), + Err(error) => println!("\ninstance_settings = \"unavailable: {error}\""), + } + } + Err(_) => println!("\ninstance_settings = \"unavailable until database initialization\""), + } } - ConfigAction::Path => { - match config::resolve_config_path(cli) { - Some(p) => println!("Active config file: {}", p.display()), - None => println!("Active config file: "), + } + Ok(()) +} + +async fn run_secrets(action: &SecretsAction) -> Result<(), Box> { + match action { + SecretsAction::Reset { yes } => { + if !yes { + return Err( + "Secret reset invalidates API tokens and integration credentials, generates a new backup encryption key, and makes existing encrypted backups unreadable. Re-run with --yes.".into(), + ); } - println!("\nSearch order (first existing wins):"); - match &cli.config { - Some(explicit) => println!(" 1. --config / VCMS_CONFIG: {}", explicit.display()), - None => println!(" 1. --config / VCMS_CONFIG: "), + let mode = if cms::service::is_installed()? { + cms::paths::RuntimeMode::Installed + } else { + cms::paths::RuntimeMode::Portable + }; + let paths = cms::paths::RuntimePaths::for_mode(mode)?; + paths.ensure()?; + cms::config::ensure_bootstrap(&paths)?; + let existing = cms::secrets::load(&paths)?.ok_or("No existing instance secrets were found to reset")?; + if existing.database_url.is_none() && !paths.database_file().exists() { + return Err("No existing instance database was found to reset".into()); } - for (i, p) in config::config_search_paths().iter().enumerate() { - let marker = if p.exists() { " (exists)" } else { "" }; - println!(" {}. {}{}", i + 2, p.display(), marker); + let old_database_url = existing.database_url; + let fresh = cms::secrets::fresh(old_database_url); + let config = cms::config::Config::load(&paths, &fresh)?; + let pool = init_db_with_config(&config).await?; + let (tokens, webhooks, s3_sites) = invalidate_credentials(&pool).await?; + cms::secrets::replace(&paths, &fresh)?; + println!("Trust root replaced. Recovery report:"); + println!("- {tokens} API access token(s) invalidated"); + println!("- Active dashboard sessions invalidated"); + println!("- {webhooks} webhook(s) disabled; secret headers cleared"); + println!("- Encrypted storage and backup credentials cleared"); + if s3_sites > 0 { + println!("- {s3_sites} S3-backed site(s) require new storage credentials"); } } } Ok(()) } -async fn run_admin(action: &AdminAction, cli: &Cli) -> Result<(), Box> { - cms::paths::ensure()?; - cms::secrets::ensure()?; - let config = Config::load(cli)?; - let pool = init_db_with_config(&config).await?; - let repository = Repository::new(&pool); +async fn invalidate_credentials(pool: &cms::database::pool::DbPool) -> Result<(i64, i64, i64), Box> { + use cms::database::pool::DbPool; + macro_rules! invalidate { + ($pool:expr, $disabled:expr) => {{ + let mut tx = $pool.begin().await?; + let tokens = sqlx::query_scalar("SELECT COUNT(*) FROM access_tokens") + .fetch_one(&mut *tx) + .await?; + let webhooks = sqlx::query_scalar("SELECT COUNT(*) FROM site_webhooks WHERE headers_encrypted <> ''") + .fetch_one(&mut *tx) + .await?; + let sites = sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(&mut *tx) + .await?; + sqlx::query("DELETE FROM access_tokens").execute(&mut *tx).await?; + sqlx::query("DELETE FROM sessions").execute(&mut *tx).await?; + sqlx::query($disabled).execute(&mut *tx).await?; + sqlx::query("UPDATE instance_settings SET credentials_encrypted = NULL") + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok((tokens, webhooks, sites)) + }}; + } + match pool { + DbPool::Postgres(pool) => invalidate!(pool, "UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE"), + DbPool::MySql(pool) => invalidate!(pool, "UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE"), + DbPool::Sqlite(pool) => invalidate!(pool, "UPDATE site_webhooks SET headers_encrypted = '', enabled = 0"), + } +} +async fn run_admin(action: &AdminAction) -> Result<(), Box> { + let context = cms::runtime::RuntimeContext::initialize_default()?; + let pool = init_db_with_config(&context.bootstrap).await?; + let repository = Repository::new(&pool); match action { AdminAction::ResetPassword { email, password } => { - let id = match repository.user.find_by_email(email).await? { - Some(user) => user.id, - None => return Err(format!("User with email '{email}' not found").into()), - }; + let id = repository + .user + .find_by_email(email) + .await? + .ok_or_else(|| format!("User with email '{email}' not found"))? + .id; let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)?; repository.user.update_password(&id, &password_hash, false).await?; println!("Password updated for user with email '{email}'."); @@ -136,16 +197,17 @@ async fn run_admin(action: &AdminAction, cli: &Cli) -> Result<(), Box Ok(()) } -async fn run_backup(action: &BackupAction, cli: &Cli) -> Result<(), Box> { +async fn run_backup(action: &BackupAction) -> Result<(), Box> { use cms::services::backup::{BackupService, CreateBackupOptions, build_backup_destination, meta}; - cms::paths::ensure()?; - cms::secrets::ensure()?; - let config = Config::load(cli)?; + let context = cms::runtime::RuntimeContext::initialize_default()?; + let mut config = context.bootstrap; let pool = init_db_with_config(&config).await?; + let settings = cms::services::settings::SettingsService::load(pool.clone(), &context.secrets.master_key).await?; + settings.apply_to_config(&mut config).await; let storage_registry = cms::server::initialize_storage(&config); let destination = build_backup_destination(&config)?; - let service = BackupService::new(pool.clone(), storage_registry, destination, &config); + let service = BackupService::new(pool.clone(), storage_registry, destination, &config).with_settings(settings); match action { BackupAction::Create { @@ -190,16 +252,16 @@ async fn run_backup(action: &BackupAction, cli: &Cli) -> Result<(), Box, import_as_new: bool, yes: bool, - cli: &Cli, ) -> Result<(), Box> { use cms::services::backup::{ BackupService, RestoreRequest, RestoreSource, RestoreTarget, build_backup_destination, }; - if !yes { - return Err("Restore is destructive and replaces data within the chosen scope. \ - Re-run with --yes to proceed." - .into()); + return Err("Restore replaces data within the chosen scope. Re-run with --yes.".into()); } - cms::paths::ensure()?; - cms::secrets::ensure()?; - let config = Config::load(cli)?; + let context = cms::runtime::RuntimeContext::initialize_default()?; + let mut config = context.bootstrap; let pool = init_db_with_config(&config).await?; + let settings = cms::services::settings::SettingsService::load(pool.clone(), &context.secrets.master_key).await?; + settings.apply_to_config(&mut config).await; let storage_registry = cms::server::initialize_storage(&config); let destination = build_backup_destination(&config)?; - let service = BackupService::new(pool.clone(), storage_registry, destination, &config); - - let bytes = std::fs::read(file)?; + let service = BackupService::new(pool, storage_registry, destination, &config).with_settings(settings); let target = match scope { "instance" => RestoreTarget::WholeInstance, - "site" => { - let sid = site.clone().ok_or("--site is required for --scope site")?; - RestoreTarget::Site { - site_id: sid, - import_as_new, - } - } + "site" => RestoreTarget::Site { + site_id: site.clone().ok_or("--site is required for --scope site")?, + import_as_new, + }, other => return Err(format!("unknown scope '{other}' (use instance|site)").into()), }; - - service + let report = service .restore(RestoreRequest { - source: RestoreSource::Bytes(bytes), + source: RestoreSource::Bytes(std::fs::read(file)?), target, created_by: None, }) .await?; - println!("Restore complete."); - let reindex_path = match (scope, site) { - ("site", Some(sid)) => format!("/api/dashboard/sites/{sid}/search/reindex"), - _ => "/api/dashboard/instance/search/reindex".to_string(), - }; - println!( - "Note: the full-text search index may now be stale. Rebuild it from the dashboard \ - (Settings → Backups → Rebuild search index) or POST {reindex_path} once the server is running." - ); + println!("Restore complete. Recovery required:"); + for item in report.recovery_required { + println!("- {item}"); + } Ok(()) } @@ -270,7 +318,7 @@ fn parse_scope(scope: &str, site: Option<&str>) -> Result Ok(Scope::Instance), "site" => site - .map(|s| Scope::Site(s.to_string())) + .map(|value| Scope::Site(value.to_owned())) .ok_or_else(|| "--site is required for --scope site".into()), other => Err(format!("unknown scope '{other}' (use instance|site)").into()), } diff --git a/apps/backend/src/mcp/auth.rs b/apps/backend/src/mcp/auth.rs index 7a4edb46..fd464556 100644 --- a/apps/backend/src/mcp/auth.rs +++ b/apps/backend/src/mcp/auth.rs @@ -80,7 +80,7 @@ pub async fn authenticate_mcp_request(mut request: Request, next: Next) -> None => return auth_response(StatusCode::UNAUTHORIZED, "Missing Authorization bearer token"), }; - match verify_access_token(&token, &repository, &config.hmac_secret).await { + match verify_access_token(&token, &repository, &config.token_index_key).await { Ok(actor) => { request.extensions_mut().insert(actor); next.run(request).await diff --git a/apps/backend/src/mcp/tools/file.rs b/apps/backend/src/mcp/tools/file.rs index 52394de5..8019c32d 100644 --- a/apps/backend/src/mcp/tools/file.rs +++ b/apps/backend/src/mcp/tools/file.rs @@ -147,7 +147,7 @@ pub async fn create_upload_url( ¶ms.0.filename, ¶ms.0.content_type, &storage_provider, - &config.hmac_secret, + &config.signed_upload_key, config.upload_token_expiry_secs, ); diff --git a/apps/backend/src/middleware/api_auth.rs b/apps/backend/src/middleware/api_auth.rs index 7f684f20..f457a08d 100644 --- a/apps/backend/src/middleware/api_auth.rs +++ b/apps/backend/src/middleware/api_auth.rs @@ -51,7 +51,7 @@ pub async fn api_auth_middleware(mut request: Request, next: Next) -> Response { } }; - let actor = match verify_access_token(&token, &repository, &config.hmac_secret).await { + let actor = match verify_access_token(&token, &repository, &config.token_index_key).await { Ok(actor) => actor, Err((status, err)) => return (status, err).into_response(), }; diff --git a/apps/backend/src/middleware/auth.rs b/apps/backend/src/middleware/auth.rs index 0d1e51dd..d9e11cb7 100644 --- a/apps/backend/src/middleware/auth.rs +++ b/apps/backend/src/middleware/auth.rs @@ -117,7 +117,7 @@ where .get::() .cloned() .ok_or_else(|| AuthError::unauthorized("Server configuration error"))?; - let actor = verify_access_token(&token, &repository, &config.hmac_secret).await?; + let actor = verify_access_token(&token, &repository, &config.token_index_key).await?; return Ok(AuthContext { actor, auth_method: AuthMethod::ApiKey, @@ -135,7 +135,7 @@ where .get::() .cloned() .ok_or_else(|| AuthError::unauthorized("Server configuration error"))?; - let user = verify_session(&token, &repository, &config.hmac_secret).await?; + let user = verify_session(&token, &repository, &config.session_auth_key).await?; return Ok(AuthContext { actor: Actor::User(user), auth_method: AuthMethod::Session, diff --git a/apps/backend/src/middleware/dashboard_auth.rs b/apps/backend/src/middleware/dashboard_auth.rs index 8be638de..a558378e 100644 --- a/apps/backend/src/middleware/dashboard_auth.rs +++ b/apps/backend/src/middleware/dashboard_auth.rs @@ -53,7 +53,7 @@ pub async fn dashboard_auth_middleware(mut request: Request, next: Next) -> Resp .into_response(); } }; - let user = match verify_session(&token, &repository, &config.hmac_secret).await { + let user = match verify_session(&token, &repository, &config.session_auth_key).await { Ok(user) => user, Err((status, error)) => return (status, error).into_response(), }; @@ -61,7 +61,7 @@ pub async fn dashboard_auth_middleware(mut request: Request, next: Next) -> Resp if matches!(request.method().as_str(), "POST" | "PUT" | "PATCH" | "DELETE") { let session = match repository .session - .find_active_by_hash(&compute_key_hmac(&token, &config.hmac_secret)) + .find_active_by_hash(&compute_key_hmac(&token, &config.session_auth_key)) .await { Ok(Some(session)) => session, @@ -74,7 +74,7 @@ pub async fn dashboard_auth_middleware(mut request: Request, next: Next) -> Resp } }; let (parts, body) = request.into_parts(); - if let Err((status, msg)) = verify_csrf(&parts, &config.hmac_secret, &session.csrf_token_hash) { + if let Err((status, msg)) = verify_csrf(&parts, &config.session_auth_key, &session.csrf_token_hash) { return (status, Json(serde_json::json!({"error": "csrf_error", "message": msg}))).into_response(); } request = Request::from_parts(parts, body); diff --git a/apps/backend/src/models/authorization.rs b/apps/backend/src/models/authorization.rs index 03dc6886..cca8baf8 100644 --- a/apps/backend/src/models/authorization.rs +++ b/apps/backend/src/models/authorization.rs @@ -79,6 +79,8 @@ impl fmt::Display for SiteRole { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Action { InstanceManage, + /// Change installation-wide runtime settings (owner-only). + InstanceSettings, SiteCreate, SiteRead, SiteManage, @@ -116,6 +118,7 @@ impl Authorizer { Some(InstanceRole::InstanceOwner) => matches!( action, Action::InstanceManage + | Action::InstanceSettings | Action::SiteCreate | Action::SiteDelete | Action::InstanceRolesGrant diff --git a/apps/backend/src/models/webhook.rs b/apps/backend/src/models/webhook.rs index a7a4b4c8..116a5100 100644 --- a/apps/backend/src/models/webhook.rs +++ b/apps/backend/src/models/webhook.rs @@ -9,6 +9,7 @@ pub struct SiteWebhook { pub label: String, pub url: String, pub headers_encrypted: String, + pub enabled: bool, pub created_by: Option, pub created_at: String, pub updated_at: String, diff --git a/apps/backend/src/paths.rs b/apps/backend/src/paths.rs index ec308682..6dc2334c 100644 --- a/apps/backend/src/paths.rs +++ b/apps/backend/src/paths.rs @@ -1,365 +1,214 @@ -//! Resolution of where the CMS keeps its runtime files. +//! Deterministic runtime layout. //! -//! There are two layouts: -//! -//! * **Split** (the default for an interactive install) — files land in the -//! platform-conventional per-type directories via the `directories` crate: -//! config/secrets in the config dir, the database/uploads/backups in the data dir, -//! the (rebuildable) search index in the cache dir, and logs in the state dir. -//! On Linux this is XDG (`~/.config/vcms`, `~/.local/share/vcms`, `~/.cache/vcms`, -//! `~/.local/state/vcms`); on macOS `~/Library/Application Support/vcms` (+ Caches); -//! on Windows `%APPDATA%`/`%LOCALAPPDATA%` under `vcms`. -//! -//! * **Single** — everything nests under one root. Chosen when `$VCMS_HOME` is set, -//! the system service home exists, or a legacy `~/.vcms` already holds data. - -use std::path::PathBuf; +//! Portable instances always use `/vcms_data`. Installed instances always +//! use the platform service root. Selection is performed once from native service +//! registration and the resulting paths are passed through startup explicitly. -/// Environment variable that forces the single-directory layout at a chosen root. -pub const CMS_HOME_ENV: &str = "VCMS_HOME"; +use std::path::{Path, PathBuf}; -/// Where each class of file lives. Resolved fresh on each call (cheap) so tests and -/// the service can change `$VCMS_HOME` without a process restart. -enum Layout { - /// One root holding everything (`$VCMS_HOME`, a legacy `~/.vcms`, or a `.vcms` - /// fallback when no platform dirs are detectable). - Single(PathBuf), - /// Per-type platform directories. - Split { - config: PathBuf, - data: PathBuf, - cache: PathBuf, - state: PathBuf, - }, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeMode { + Portable, + Installed, } -impl Layout { - fn config(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { config, .. } => config.clone(), - } +impl std::fmt::Display for RuntimeMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Portable => "portable", + Self::Installed => "installed", + }) } +} - fn data(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { data, .. } => data.clone(), +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimePaths { + mode: RuntimeMode, + root: PathBuf, +} + +impl RuntimePaths { + pub fn portable(cwd: impl AsRef) -> Self { + Self { + mode: RuntimeMode::Portable, + root: cwd.as_ref().join("vcms_data"), } } - fn cache(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { cache, .. } => cache.clone(), - } + pub fn installed() -> Result { + Ok(Self { + mode: RuntimeMode::Installed, + root: system_root().ok_or_else(|| "installed mode is unsupported on this platform".to_owned())?, + }) } - fn state(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { state, .. } => state.clone(), + pub fn for_mode(mode: RuntimeMode) -> Result { + match mode { + RuntimeMode::Portable => std::env::current_dir() + .map(Self::portable) + .map_err(|error| format!("cannot resolve current directory: {error}")), + RuntimeMode::Installed => Self::installed(), } } -} -/// Conventional system directory an installed OS service owns, per platform. The app -/// does not auto-select this path; service packages pass it through `$VCMS_HOME`. -pub fn system_home() -> Option { - #[cfg(windows)] - { - Some(PathBuf::from(r"C:\ProgramData\vcms")) - } - #[cfg(target_os = "linux")] - { - Some(PathBuf::from("/var/lib/vcms")) + pub fn mode(&self) -> RuntimeMode { + self.mode } - #[cfg(target_os = "macos")] - { - Some(PathBuf::from("/Library/Application Support/vcms")) - } - #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] - { - None - } -} -/// Resolve the active layout: `$VCMS_HOME` → system home → legacy `~/.vcms` → platform split. -/// Gathers the real filesystem/env inputs and defers the precedence decision to the -/// pure [`resolve_layout`] (kept separate so it is testable without touching real -/// system dirs). -fn layout() -> Layout { - let env_home = std::env::var_os(CMS_HOME_ENV) - .filter(|v| !v.is_empty()) - .map(PathBuf::from); - let system = system_home().filter(|path| path.is_dir()); + pub fn root(&self) -> &Path { + &self.root + } - // Back-compat: a pre-existing single `~/.vcms` keeps owning everything so an upgrade - // never strands a user's database or secrets. - let legacy = directories::BaseDirs::new() - .map(|base| base.home_dir().join(".vcms")) - .filter(|p| p.is_dir()); + pub fn config_file(&self) -> PathBuf { + self.root.join("config.toml") + } - let split = directories::ProjectDirs::from("", "", "vcms").map(|dirs| split_from(&dirs)); + pub fn secrets_file(&self) -> PathBuf { + self.root.join("secrets.toml") + } - resolve_layout(env_home, system, legacy, split) -} + pub fn database_file(&self) -> PathBuf { + self.root.join("vcms.db") + } -/// Pure precedence policy. Each `Option` is a candidate the caller has already vetted -/// (env set & non-empty; legacy dir confirmed to exist; split from `ProjectDirs`). -/// Falls back to a local `.vcms` blob when no platform dirs are detectable (rare). -fn resolve_layout( - env_home: Option, - system_home: Option, - legacy_home: Option, - split: Option, -) -> Layout { - if let Some(root) = env_home { - return Layout::Single(root); + pub fn storage_dir(&self) -> PathBuf { + self.root.join("storage") } - if let Some(root) = system_home { - return Layout::Single(root); + + pub fn backups_dir(&self) -> PathBuf { + self.root.join("backups") } - if let Some(root) = legacy_home { - return Layout::Single(root); + + pub fn search_dir(&self) -> PathBuf { + self.root.join("search") } - split.unwrap_or_else(|| Layout::Single(PathBuf::from(".vcms"))) -} -/// Map `ProjectDirs` onto our four directory classes. Logs use the state dir where -/// the platform has one (Linux), else the local data dir (macOS/Windows). -fn split_from(dirs: &directories::ProjectDirs) -> Layout { - Layout::Split { - config: dirs.config_dir().to_path_buf(), - data: dirs.data_dir().to_path_buf(), - cache: dirs.cache_dir().to_path_buf(), - state: dirs.state_dir().unwrap_or_else(|| dirs.data_local_dir()).to_path_buf(), + pub fn logs_dir(&self) -> PathBuf { + self.root.join("logs") } -} -/// `config.toml` — the non-secret config file (config dir). -pub fn config_file() -> PathBuf { - layout().config().join("config.toml") -} + pub fn database_url(&self) -> String { + format!("sqlite://{}", self.database_file().to_string_lossy().replace('\\', "/")) + } -/// `secrets.toml` — auto-generated secrets file (config dir, 0600 on unix). -pub fn secrets_file() -> PathBuf { - layout().config().join("secrets.toml") + pub fn ensure(&self) -> std::io::Result<()> { + create_private_dir(&self.root)?; + for path in [ + self.storage_dir(), + self.backups_dir(), + self.search_dir(), + self.logs_dir(), + ] { + create_private_dir(&path)?; + } + Ok(()) + } } -/// `.env` — optional environment file loaded at startup (config dir). -pub fn env_file() -> PathBuf { - layout().config().join(".env") +#[cfg(unix)] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::create_dir_all(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) } -/// `vcms.db` — the default SQLite database file (data dir). -pub fn default_db_path() -> PathBuf { - layout().data().join("vcms.db") +#[cfg(windows)] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(path)?; + harden_windows_acl(path, true) } -/// `storage/` — default filesystem storage directory for uploads (data dir). -pub fn storage_dir() -> PathBuf { - layout().data().join("storage") +#[cfg(windows)] +pub(crate) fn harden_windows_acl(path: &Path, directory: bool) -> std::io::Result<()> { + use std::io::{Error, ErrorKind}; + let whoami = std::process::Command::new("whoami.exe").output()?; + if !whoami.status.success() { + return Err(Error::other("whoami failed while hardening ACL")); + } + let user = String::from_utf8(whoami.stdout).map_err(|error| Error::new(ErrorKind::InvalidData, error))?; + let user = user.trim(); + let suffix = if directory { ":(OI)(CI)F" } else { ":F" }; + let grants = [ + format!("{user}{suffix}"), + format!("*S-1-5-18{suffix}"), + format!("*S-1-5-32-544{suffix}"), + ]; + let status = std::process::Command::new("icacls.exe") + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .args(grants) + .status()?; + if status.success() { + Ok(()) + } else { + Err(Error::other("icacls failed while hardening ACL")) + } } -/// `backups/` — default local destination for backup artifacts (data dir). -pub fn backups_dir() -> PathBuf { - layout().data().join("backups") +#[cfg(not(any(unix, windows)))] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(path) } -/// `search/` — default location for the derived Tantivy index (cache dir). -pub fn search_dir() -> PathBuf { - layout().cache().join("search") +#[cfg(unix)] +pub fn permissions_secure(path: &Path, expected: u32) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o777 == expected) } -/// `logs/` — directory for rolling log files (state dir). -pub fn logs_dir() -> PathBuf { - layout().state().join("logs") +#[cfg(windows)] +pub fn permissions_secure(path: &Path, _expected: u32) -> bool { + let Ok(output) = std::process::Command::new("icacls.exe").arg(path).output() else { + return false; + }; + let acl = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + output.status.success() + && !acl.contains("(i)") + && !acl.contains("everyone:") + && !acl.contains("authenticated users:") + && !acl.contains("builtin\\users:") } -/// Build the default `DATABASE_URL` (`sqlite:///vcms.db`). -/// -/// SQLite URLs use forward slashes, so backslashes are normalized for Windows. -pub fn default_database_url() -> String { - format!("sqlite://{}", default_db_path().to_string_lossy().replace('\\', "/")) +#[cfg(not(any(unix, windows)))] +pub fn permissions_secure(_path: &Path, _expected: u32) -> bool { + true } -/// Fail fast when the active root is the conventional system service home but this -/// process can't access it (e.g. non-elevated CLI against the ACL-locked -/// `C:\ProgramData\vcms` or root-owned `/var/lib/vcms`). -fn preflight_system_home() -> std::io::Result<()> { - let Layout::Single(root) = layout() else { - return Ok(()); - }; - if system_home().as_deref() != Some(root.as_path()) || !root.is_dir() { - return Ok(()); +pub fn system_root() -> Option { + #[cfg(windows)] + { + Some(PathBuf::from(r"C:\ProgramData\vcms")) } - // A read probe: opening the db and secrets both need list+read on this dir. - match std::fs::read_dir(&root) { - Ok(_) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!( - "vcms data at {} requires Administrator/root; re-run in an elevated terminal.", - root.display() - ), - )), - Err(e) => Err(e), + #[cfg(target_os = "linux")] + { + Some(PathBuf::from("/var/lib/vcms")) } -} - -/// Create the directories the running instance writes to (config, data, storage, -/// logs). Called by commands that own/initialize the instance (`serve`, `admin`, -/// `backup`, `restore`); read-only commands such as `mcp stdio`, `config path/show` -/// never call this and so never create anything or trip the preflight below. -pub fn ensure() -> std::io::Result<()> { - preflight_system_home()?; - let layout = layout(); - for dir in [ - layout.config(), - layout.data(), - layout.state().join("logs"), - layout.data().join("storage"), - ] { - std::fs::create_dir_all(dir)?; + #[cfg(target_os = "macos")] + { + Some(PathBuf::from("/Library/Application Support/vcms")) } - Ok(()) -} - -/// Whether a file sits inside the home root in single-dir mode. Only meaningful for -/// `$VCMS_HOME`/legacy installs; in split mode there is no single root. -pub fn single_root() -> Option { - match layout() { - Layout::Single(root) => Some(root), - Layout::Split { .. } => None, + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + { + None } } #[cfg(test)] mod tests { use super::*; - use crate::test_helpers::with_home; - use std::path::Path; - - #[test] - fn vcms_home_forces_single_layout() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - assert_eq!(single_root().as_deref(), Some(dir.path())); - assert_eq!(config_file(), dir.path().join("config.toml")); - assert_eq!(secrets_file(), dir.path().join("secrets.toml")); - assert_eq!(env_file(), dir.path().join(".env")); - assert_eq!(default_db_path(), dir.path().join("vcms.db")); - assert_eq!(storage_dir(), dir.path().join("storage")); - assert_eq!(backups_dir(), dir.path().join("backups")); - assert_eq!(search_dir(), dir.path().join("search")); - assert_eq!(logs_dir(), dir.path().join("logs")); - }); - } - - #[test] - fn ensure_creates_subdirectories_in_single_mode() { - let dir = tempfile::tempdir().expect("temp dir"); - let root = dir.path().join("home"); - with_home(&root, || { - ensure().expect("ensure should create dirs"); - assert!(root.is_dir()); - assert!(logs_dir().is_dir()); - assert!(storage_dir().is_dir()); - }); - } - - #[test] - fn default_database_url_uses_forward_slashes() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - let url = default_database_url(); - assert!(url.starts_with("sqlite://")); - assert!(!url.contains('\\')); - }); - } - - #[test] - fn split_layout_separates_file_classes() { - // Drive the pure mapping directly so the assertion is platform-independent - // (real ProjectDirs vary by OS and ambient env). - let layout = Layout::Split { - config: PathBuf::from("/cfg"), - data: PathBuf::from("/dat"), - cache: PathBuf::from("/cch"), - state: PathBuf::from("/st"), - }; - assert!(layout.config().join("config.toml").starts_with(Path::new("/cfg"))); - assert!(layout.data().join("vcms.db").starts_with(Path::new("/dat"))); - assert!(layout.cache().join("search").starts_with(Path::new("/cch"))); - assert!(layout.state().join("logs").starts_with(Path::new("/st"))); - // config / data / cache / state are genuinely distinct in split mode. - assert_ne!(layout.config(), layout.data()); - assert_ne!(layout.data(), layout.cache()); - assert_ne!(layout.cache(), layout.state()); - } - - #[test] - fn split_state_falls_back_to_local_data_when_absent() { - // When a platform lacks a state dir, logs must still resolve (we feed the - // local-data dir into `state`). Modeled here as state == data. - let layout = Layout::Split { - config: PathBuf::from("/cfg"), - data: PathBuf::from("/dat"), - cache: PathBuf::from("/cch"), - state: PathBuf::from("/dat"), - }; - assert!(layout.state().join("logs").starts_with(Path::new("/dat"))); - } - - fn split_sample() -> Option { - Some(Layout::Split { - config: PathBuf::from("/c"), - data: PathBuf::from("/d"), - cache: PathBuf::from("/ch"), - state: PathBuf::from("/s"), - }) - } - - fn single_root_of(layout: Layout) -> Option { - match layout { - Layout::Single(root) => Some(root), - Layout::Split { .. } => None, - } - } - - #[test] - fn resolve_env_home_outranks_everything() { - let out = resolve_layout( - Some(PathBuf::from("/env")), - Some(PathBuf::from("/system")), - Some(PathBuf::from("/legacy")), - split_sample(), - ); - assert_eq!(single_root_of(out), Some(PathBuf::from("/env"))); - } - - #[test] - fn resolve_system_home_when_present() { - let out = resolve_layout(None, Some(PathBuf::from("/system")), None, split_sample()); - assert_eq!(single_root_of(out), Some(PathBuf::from("/system"))); - } - - #[test] - fn resolve_legacy_home_when_no_env_or_system() { - let out = resolve_layout(None, None, Some(PathBuf::from("/legacy")), split_sample()); - assert_eq!(single_root_of(out), Some(PathBuf::from("/legacy"))); - } #[test] - fn resolve_falls_through_to_split() { - let out = resolve_layout(None, None, None, split_sample()); - assert!(matches!(out, Layout::Split { .. })); + fn portable_layout_is_one_directory_under_cwd() { + let paths = RuntimePaths::portable(Path::new("/work/site")); + assert_eq!(paths.root(), Path::new("/work/site/vcms_data")); + assert_eq!(paths.config_file(), Path::new("/work/site/vcms_data/config.toml")); + assert_eq!(paths.database_file(), Path::new("/work/site/vcms_data/vcms.db")); + assert_eq!(paths.storage_dir(), Path::new("/work/site/vcms_data/storage")); } #[test] - fn resolve_local_blob_when_nothing_detectable() { - let out = resolve_layout(None, None, None, None); - assert_eq!(single_root_of(out), Some(PathBuf::from(".vcms"))); + fn sqlite_url_uses_forward_slashes() { + let paths = RuntimePaths::portable(Path::new(r"C:\work")); + assert!(!paths.database_url().contains('\\')); } } diff --git a/apps/backend/src/repository/mysql/webhook.rs b/apps/backend/src/repository/mysql/webhook.rs index e700fc89..ff3cc634 100644 --- a/apps/backend/src/repository/mysql/webhook.rs +++ b/apps/backend/src/repository/mysql/webhook.rs @@ -19,7 +19,7 @@ impl MysqlWebhookRepository { impl WebhookRepository for MysqlWebhookRepository { async fn list_for_site(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", ) .bind(site_id) .fetch_all(&self.pool) @@ -30,7 +30,7 @@ impl WebhookRepository for MysqlWebhookRepository { async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", ) .bind(id) .bind(site_id) @@ -158,7 +158,7 @@ impl WebhookRepository for MysqlWebhookRepository { impl MysqlWebhookRepository { async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/repository/postgres/webhook.rs b/apps/backend/src/repository/postgres/webhook.rs index 56e45f41..ff12ef30 100644 --- a/apps/backend/src/repository/postgres/webhook.rs +++ b/apps/backend/src/repository/postgres/webhook.rs @@ -19,7 +19,7 @@ impl PostgresWebhookRepository { impl WebhookRepository for PostgresWebhookRepository { async fn list_for_site(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE site_id = $1 ORDER BY created_at", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE site_id = $1 ORDER BY created_at", ) .bind(site_id) .fetch_all(&self.pool) @@ -30,7 +30,7 @@ impl WebhookRepository for PostgresWebhookRepository { async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1 AND site_id = $2", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1 AND site_id = $2", ) .bind(id) .bind(site_id) @@ -160,7 +160,7 @@ impl WebhookRepository for PostgresWebhookRepository { impl PostgresWebhookRepository { async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/repository/sqlite/webhook.rs b/apps/backend/src/repository/sqlite/webhook.rs index 3aea7246..420e649a 100644 --- a/apps/backend/src/repository/sqlite/webhook.rs +++ b/apps/backend/src/repository/sqlite/webhook.rs @@ -19,7 +19,7 @@ impl SqliteWebhookRepository { impl WebhookRepository for SqliteWebhookRepository { async fn list_for_site(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at, updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at, updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", ) .bind(site_id) .fetch_all(&self.pool) @@ -30,7 +30,7 @@ impl WebhookRepository for SqliteWebhookRepository { async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at, updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at, updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", ) .bind(id) .bind(site_id) @@ -158,7 +158,7 @@ impl WebhookRepository for SqliteWebhookRepository { impl SqliteWebhookRepository { async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at, updated_at FROM site_webhooks WHERE id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at, updated_at FROM site_webhooks WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/router/files.rs b/apps/backend/src/router/files.rs index 257fac30..72a8d0ce 100644 --- a/apps/backend/src/router/files.rs +++ b/apps/backend/src/router/files.rs @@ -3,7 +3,6 @@ use axum::{ Router, routing::{delete, get, post, put}, }; -use tower_http::limit::RequestBodyLimitLayer; use crate::handlers::file_handler::{ batch_delete_files, batch_permanent_delete_files, batch_restore_files, delete_file_handler, get_file, @@ -12,14 +11,13 @@ use crate::handlers::file_handler::{ }; /// Public API CRUD routes (mounted at /api/v1) -pub fn public_routes(max_upload_bytes: usize) -> Router { +pub fn public_routes(_max_upload_bytes: usize) -> Router { Router::new() .route("/files", get(list_files)) .merge( Router::new() .route("/files", post(upload_file)) - .layer(DefaultBodyLimit::disable()) - .layer(RequestBodyLimitLayer::new(max_upload_bytes)), + .layer(DefaultBodyLimit::disable()), ) .route("/files/batch-delete", post(batch_delete_files)) .route("/files/batch-restore", post(batch_restore_files)) @@ -40,22 +38,20 @@ pub fn file_serve_routes() -> Router { /// Signed-URL upload — standalone, no auth middleware: the HMAC token in the /// path is the credential (minted by the MCP `create_upload_url` tool). /// Registered at the literal path the tool advertises. -pub fn signed_upload_routes(max_upload_bytes: usize) -> Router { +pub fn signed_upload_routes(_max_upload_bytes: usize) -> Router { Router::new() .route("/api/v1/files/upload/{token}", put(upload_via_signed_url)) .layer(DefaultBodyLimit::disable()) - .layer(RequestBodyLimitLayer::new(max_upload_bytes)) } /// Dashboard routes (mounted under /api/dashboard/sites/{site_id}) -pub fn dashboard_routes(max_upload_bytes: usize) -> Router { +pub fn dashboard_routes(_max_upload_bytes: usize) -> Router { Router::new() .route("/files", get(list_files)) .merge( Router::new() .route("/files", post(upload_file)) - .layer(DefaultBodyLimit::disable()) - .layer(RequestBodyLimitLayer::new(max_upload_bytes)), + .layer(DefaultBodyLimit::disable()), ) .route("/files/batch-delete", post(batch_delete_files)) .route("/files/batch-restore", post(batch_restore_files)) diff --git a/apps/backend/src/router/graphql.rs b/apps/backend/src/router/graphql.rs index ffa91f0c..45148950 100644 --- a/apps/backend/src/router/graphql.rs +++ b/apps/backend/src/router/graphql.rs @@ -30,7 +30,7 @@ async fn graphql_handler( tokio::spawn, ); - let gql_ctx = GqlContext::from_request(repository, services, auth_header, &config.hmac_secret).await; + let gql_ctx = GqlContext::from_request(repository, services, auth_header, &config.token_index_key).await; let response = schema.execute(req.into_inner().data(gql_ctx).data(entry_loader)).await; async_graphql_axum::GraphQLResponse::from(response) diff --git a/apps/backend/src/router/instance.rs b/apps/backend/src/router/instance.rs index 1cb6f7f4..f1171b20 100644 --- a/apps/backend/src/router/instance.rs +++ b/apps/backend/src/router/instance.rs @@ -6,6 +6,9 @@ use axum::{ use crate::handlers::instance_handler::{ create_user, delete_user, list_users, set_user_password, update_instance_role, update_user, }; +use crate::handlers::settings_handler::{ + get_settings, update_backups, update_general, update_security, update_storage, +}; pub fn routes() -> Router { Router::new() @@ -15,4 +18,9 @@ pub fn routes() -> Router { .route("/instance/users/{user_id}", delete(delete_user)) .route("/instance/users/{user_id}/role", put(update_instance_role)) .route("/instance/users/{user_id}/password", post(set_user_password)) + .route("/instance/settings", get(get_settings)) + .route("/instance/settings/general", put(update_general)) + .route("/instance/settings/security", put(update_security)) + .route("/instance/settings/storage", put(update_storage)) + .route("/instance/settings/backups", put(update_backups)) } diff --git a/apps/backend/src/router/mod.rs b/apps/backend/src/router/mod.rs index 2322fe1b..dc9a759e 100644 --- a/apps/backend/src/router/mod.rs +++ b/apps/backend/src/router/mod.rs @@ -17,11 +17,17 @@ mod webhooks; use std::sync::Arc; +use axum::body::Body; use axum::extract::DefaultBodyLimit; -use axum::http::{HeaderName, Method, header}; -use axum::{Extension, Router, middleware::from_fn, routing::get}; +use axum::extract::{Request, State}; +use axum::http::{HeaderValue, Method, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::{ + Extension, Router, + middleware::{Next, from_fn, from_fn_with_state}, + routing::get, +}; use tokio_util::sync::CancellationToken; -use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::trace::TraceLayer; use crate::config::Config; @@ -35,6 +41,7 @@ use crate::middleware::site_resolver::{api_site_resolver, dashboard_site_resolve use crate::repository::Repository; use crate::services::Services; use crate::services::backup::BackupService; +use crate::services::settings::SettingsService; use crate::storage::StorageRegistry; use crate::tracing::trace_request; @@ -84,6 +91,7 @@ pub fn create_router( storage_registry: Arc, services: Services, backup: Arc, + settings: SettingsService, ) -> Router { let rate_limiter = RateLimiter::new( config.rate_limit_max_requests, @@ -92,49 +100,13 @@ pub fn create_router( ); let max_upload_bytes = config.max_upload_size_bytes; - let cors = if config.allowed_origins.is_empty() { - // No cross-origin access configured: same-origin only. Emitting no - // Access-Control-Allow-Origin header makes browsers block cross-site reads. - CorsLayer::new() - } else { - let origins = config - .allowed_origins - .iter() - .filter_map(|origin| origin.parse().ok()) - .collect::>(); - // Explicit method + header allow-lists are required for credentialed CORS; - // `Any` is silently ignored by browsers when credentials are allowed. - CorsLayer::new() - .allow_origin(AllowOrigin::list(origins)) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::PATCH, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([ - header::AUTHORIZATION, - header::CONTENT_TYPE, - header::ACCEPT, - HeaderName::from_static("x-csrf-token"), - ]) - .allow_credentials(true) - }; - - let mcp_enabled = config.mcp_enabled; // Share the single `Services` (and its single-writer search index) with the // MCP HTTP server instead of constructing a second one. - let mcp_services = if mcp_enabled { - Some(Arc::new(services.clone())) - } else { - None - }; + let mcp_services = Arc::new(services.clone()); let mut router = Router::new() // Public, minimal operational probes. Detailed failures remain in logs/doctor. - .merge(health::routes(pool)) + .merge(health::routes(pool.clone())) // ── Auth (no middleware) ── .merge(auth::auth_routes()) // ── Public API (/api/v1/*) ── @@ -168,28 +140,91 @@ pub fn create_router( // ── Global layers ── .layer(from_fn(trace_request)) .layer(TraceLayer::new_for_http()) - .layer(cors) - .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) + .layer(from_fn_with_state(settings.clone(), dynamic_runtime_policy)) + .layer(DefaultBodyLimit::max(1024 * 1024 * 1024)) .layer(Extension(repository.clone())) .layer(Extension(config.clone())) .layer(Extension(storage_registry.clone())) .layer(Extension(services)) .layer(Extension(backup)) + .layer(Extension(settings.clone())) + .layer(Extension(pool)) .layer(Extension(rate_limiter)); - if mcp_enabled { - let mcp_ct = CancellationToken::new(); - let mcp_router = mcp::mcp_routes( - mcp_services.expect("mcp services present when mcp enabled"), - Arc::new(repository), - Arc::new(config), - storage_registry, - mcp_ct, - ); - router = router.merge(mcp_router); + let mcp_ct = CancellationToken::new(); + let mcp_router = mcp::mcp_routes( + mcp_services, + Arc::new(repository), + Arc::new(config), + storage_registry, + mcp_ct, + ); + router = router.merge(mcp_router.layer(from_fn_with_state(settings, dynamic_runtime_policy))); + + router +} + +async fn dynamic_runtime_policy(State(service): State, request: Request, next: Next) -> Response { + let settings = service.current(); + let path = request.uri().path(); + if path.starts_with("/mcp") && !settings.general.mcp_enabled { + return StatusCode::NOT_FOUND.into_response(); + } + if path.contains("upload") + && request + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|size| size > settings.general.upload_limit_mb * 1024 * 1024) + { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "upload exceeds the configured instance limit", + ) + .into_response(); + } + + let origin = request.headers().get(header::ORIGIN).cloned(); + let allowed = origin.as_ref().is_some_and(|origin| { + origin + .to_str() + .is_ok_and(|value| settings.security.allowed_origins.iter().any(|item| item == value)) + }); + if request.method() == Method::OPTIONS && origin.is_some() { + if !allowed { + return StatusCode::FORBIDDEN.into_response(); + } + return cors_response(StatusCode::NO_CONTENT.into_response(), origin); + } + let response = next.run(request).await; + if allowed { + cors_response(response, origin) } else { - drop(repository); + response } +} - router +fn cors_response(mut response: Response, origin: Option) -> Response { + if let Some(origin) = origin { + response + .headers_mut() + .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, PUT, PATCH, DELETE, OPTIONS"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("authorization, content-type, accept, x-csrf-token"), + ); + response + .headers_mut() + .append(header::VARY, HeaderValue::from_static("Origin")); + } + response } diff --git a/apps/backend/src/runtime.rs b/apps/backend/src/runtime.rs new file mode 100644 index 00000000..4b5a404f --- /dev/null +++ b/apps/backend/src/runtime.rs @@ -0,0 +1,38 @@ +use crate::config::Config; +use crate::paths::{RuntimeMode, RuntimePaths}; +use crate::secrets::PersistedSecrets; + +#[derive(Clone, Debug)] +pub struct RuntimeContext { + pub mode: RuntimeMode, + pub paths: RuntimePaths, + pub bootstrap: Config, + pub secrets: PersistedSecrets, +} + +impl RuntimeContext { + pub fn initialize(mode: RuntimeMode) -> Result> { + let paths = RuntimePaths::for_mode(mode)?; + paths + .ensure() + .map_err(|error| format!("preparing {}: {error}", paths.root().display()))?; + crate::config::ensure_bootstrap(&paths)?; + let secrets = crate::secrets::ensure(&paths)?; + let bootstrap = Config::load(&paths, &secrets)?; + Ok(Self { + mode, + paths, + bootstrap, + secrets, + }) + } + + pub fn initialize_default() -> Result> { + let mode = if crate::service::is_installed()? { + RuntimeMode::Installed + } else { + RuntimeMode::Portable + }; + Self::initialize(mode) + } +} diff --git a/apps/backend/src/secrets.rs b/apps/backend/src/secrets.rs index 18e6e16d..13c06e97 100644 --- a/apps/backend/src/secrets.rs +++ b/apps/backend/src/secrets.rs @@ -1,97 +1,171 @@ -//! Persisted instance secrets (`secrets.toml` in the config dir). -//! -//! On first `serve`/`admin`, a random `HMAC_SECRET` value is -//! generated and written to `secrets.toml` (perms `0600` on unix). Every later -//! process — including a `vcms mcp stdio` child launched from an unknown working -//! directory — reads the *same* values, so site-token verification matches the -//! server that signed the token. Environment variables still override the file. -//! -//! These secrets intentionally live in a dedicated, restricted file rather than -//! `config.toml`: the TOML config is for non-secret settings, while this file is -//! machine-managed and never scaffolded by `vcms config init`. +//! Restricted instance trust root. + +use std::io::Write; use rand::Rng; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; -use crate::paths; +use crate::paths::RuntimePaths; -/// Auto-generated secrets persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PersistedSecrets { - pub hmac_secret: String, - /// AES-256 key (hex, 64 chars) used to encrypt backup artifacts. Added in a - /// later release, so existing `secrets.toml` files may lack it; [`ensure`] - /// backfills and persists it on next run. Overridable via `BACKUP_ENCRYPTION_KEY`. - #[serde(default)] - pub backup_encryption_key: Option, + /// Root key used only to derive purpose-specific runtime keys. + pub master_key: String, + /// Independent key for encrypted backup artifacts. + pub backup_encryption_key: String, + /// External database URLs may contain credentials and therefore live here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_url: Option, } -/// Read the persisted secrets file if it exists. -/// -/// Returns `None` when the file is absent (a fresh, uninitialized instance) and -/// an error only when the file exists but cannot be read or parsed. -pub fn load() -> Result, Box> { - let path = paths::secrets_file(); +pub fn load(paths: &RuntimePaths) -> Result, Box> { + let path = paths.secrets_file(); if !path.exists() { return Ok(None); } let contents = std::fs::read_to_string(&path)?; let secrets: PersistedSecrets = toml::from_str(&contents)?; + validate(&secrets)?; Ok(Some(secrets)) } -/// Load existing secrets, or generate and persist new ones if absent. -/// -/// Called by instance-owning commands (`serve`, `admin`) before loading config. -/// Read-only commands (`mcp stdio`) must use [`load`] instead and never create -/// the file. -pub fn ensure() -> Result> { - if let Some(mut existing) = load()? { - // Backfill the backup key for instances created before it existed. - if existing.backup_encryption_key.is_none() { - existing.backup_encryption_key = Some(random_hex(32)); - persist(&existing)?; - } +/// Create secrets only for a genuinely fresh instance. Existing data without its +/// trust root must stop for explicit recovery instead of silently changing keys. +pub fn ensure(paths: &RuntimePaths) -> Result> { + if let Some(existing) = load(paths)? { return Ok(existing); } + if paths.database_file().exists() { + return Err(format!( + "{} is missing but {} already exists; restore secrets.toml or run `vcms secrets reset --yes`", + paths.secrets_file().display(), + paths.database_file().display() + ) + .into()); + } + + let secrets = fresh(None); + persist_new(paths, &secrets)?; + Ok(secrets) +} + +pub fn fresh(database_url: Option) -> PersistedSecrets { + PersistedSecrets { + master_key: random_hex(32), + backup_encryption_key: random_hex(32), + database_url, + } +} + +pub fn replace(paths: &RuntimePaths, secrets: &PersistedSecrets) -> Result<(), Box> { + validate(secrets)?; + let target = paths.secrets_file(); + let temp = target.with_extension("toml.tmp"); + write_restricted(&temp, &toml::to_string_pretty(secrets)?, false)?; + #[cfg(not(windows))] + std::fs::rename(&temp, &target)?; + #[cfg(windows)] + { + replace_file_windows(&target, &temp)?; + restrict_permissions(&target)?; + } + Ok(()) +} + +#[cfg(windows)] +fn replace_file_windows(target: &std::path::Path, replacement: &std::path::Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; - let secrets = PersistedSecrets { - hmac_secret: random_hex(32), - backup_encryption_key: Some(random_hex(32)), + let target: Vec = target.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); + let replacement: Vec = replacement + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let replaced = unsafe { + windows_sys::Win32::Storage::FileSystem::ReplaceFileW( + target.as_ptr(), + replacement.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) }; + if replaced == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} - persist(&secrets)?; - Ok(secrets) +fn persist_new(paths: &RuntimePaths, secrets: &PersistedSecrets) -> Result<(), Box> { + write_restricted(&paths.secrets_file(), &toml::to_string_pretty(secrets)?, true) } -/// Write the secrets file with owner-only permissions. -fn persist(secrets: &PersistedSecrets) -> Result<(), Box> { - let path = paths::secrets_file(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; +fn write_restricted(path: &std::path::Path, body: &str, create_new: bool) -> Result<(), Box> { + let mut options = std::fs::OpenOptions::new(); + options + .write(true) + .create(true) + .truncate(!create_new) + .create_new(create_new); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); } - let body = toml::to_string_pretty(secrets)?; - std::fs::write(&path, body)?; - restrict_permissions(&path)?; + let mut file = options.open(path)?; + file.write_all(body.as_bytes())?; + file.sync_all()?; + restrict_permissions(path)?; Ok(()) } -/// Generate `n` random bytes, hex-encoded (so a 32-byte secret is 64 chars). +fn validate(secrets: &PersistedSecrets) -> Result<(), Box> { + for (name, value) in [ + ("master_key", secrets.master_key.as_str()), + ("backup_encryption_key", secrets.backup_encryption_key.as_str()), + ] { + if value.len() != 64 || hex::decode(value).is_err() { + return Err(format!("{name} must be exactly 32 bytes encoded as 64 hexadecimal characters").into()); + } + } + Ok(()) +} + +/// Derive a stable, domain-separated 32-byte key without exposing the root key to +/// consumers. The hexadecimal result preserves existing service interfaces. +pub fn derive_key_hex(master_key: &str, purpose: &str) -> String { + let root = hex::decode(master_key).expect("validated master key"); + let mut digest = Sha256::new(); + digest.update(b"vcms-key-v1\0"); + digest.update(purpose.as_bytes()); + digest.update(b"\0"); + digest.update(root); + hex::encode(digest.finalize()) +} + fn random_hex(n: usize) -> String { let mut bytes = vec![0u8; n]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } -/// Tighten the secrets file to owner-only read/write where the platform supports -/// it. Best-effort on Windows (filesystem ACLs already restrict to the user). #[cfg(unix)] fn restrict_permissions(path: &std::path::Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) } -#[cfg(not(unix))] +#[cfg(windows)] +fn restrict_permissions(path: &std::path::Path) -> std::io::Result<()> { + crate::paths::harden_windows_acl(path, false) +} + +#[cfg(not(any(unix, windows)))] fn restrict_permissions(_path: &std::path::Path) -> std::io::Result<()> { Ok(()) } @@ -99,27 +173,23 @@ fn restrict_permissions(_path: &std::path::Path) -> std::io::Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::test_helpers::with_home; #[test] - fn load_returns_none_when_absent() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - assert!(load().expect("load ok").is_none()); - }); + fn fresh_secrets_are_valid_and_domain_separated() { + let secrets = fresh(None); + validate(&secrets).unwrap(); + assert_ne!( + derive_key_hex(&secrets.master_key, "access-token-index"), + derive_key_hex(&secrets.master_key, "webhook-encryption") + ); } #[test] - fn ensure_generates_then_reuses_secrets() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - let first = ensure().expect("generate"); - assert_eq!(first.hmac_secret.len(), 64); - assert!(paths::secrets_file().exists()); - - // A second call must return the persisted values, not regenerate. - let second = ensure().expect("reuse"); - assert_eq!(first.hmac_secret, second.hmac_secret); - }); + fn missing_secrets_are_not_recreated_over_existing_database() { + let dir = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::portable(dir.path()); + paths.ensure().unwrap(); + std::fs::write(paths.database_file(), b"db").unwrap(); + assert!(ensure(&paths).unwrap_err().to_string().contains("secrets reset")); } } diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index 3ffdbd2d..dc7ba134 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -14,7 +14,6 @@ use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::cli::Cli; use crate::config::Config; use crate::database::init_db_with_config; use crate::grpc::server::spawn_grpc_server; @@ -35,16 +34,11 @@ const ADMIN_EMAIL: &str = "admin@cms.local"; /// database open + migrated and both listeners bound — so a service host (the /// Windows SCM runner) can report `Running` truthfully instead of optimistically. pub async fn run( - cli: &Cli, + runtime: crate::runtime::RuntimeContext, shutdown: impl Future + Send + 'static, on_ready: impl FnOnce(), ) -> Result<(), Box> { - // Each early step gets a context prefix: a bare io error ("Access is denied. - // (os error 5)") from a service host is undebuggable without knowing which - // file/step produced it. - crate::paths::ensure().map_err(|e| format!("preparing data directories: {e}"))?; - crate::secrets::ensure().map_err(|e| format!("initializing secrets.toml: {e}"))?; - let config = Config::load(cli).map_err(|e| format!("loading configuration: {e}"))?; + let mut config = runtime.bootstrap.clone(); let _guard = crate::tracing::init_tracing(&config); @@ -56,6 +50,14 @@ pub async fn run( .await .map_err(|e| format!("opening database: {e}"))?; + let settings = crate::services::settings::SettingsService::load(pool.clone(), &runtime.secrets.master_key) + .await + .map_err(|error| format!("loading instance settings: {error}"))?; + settings.apply_to_config(&mut config).await; + config + .validate_security() + .map_err(|error| format!("Invalid persisted security configuration: {error}"))?; + let repository = Repository::new(&pool); seed_admin(&repository).await; @@ -65,16 +67,21 @@ pub async fn run( // same `Services` so the single-writer search index is opened only once. let repository_arc = Arc::new(repository.clone()); let config_arc = Arc::new(config.clone()); - let services = Services::new(repository_arc.clone(), &pool, &config); + let mut services = Services::new(repository_arc.clone(), &pool, &config); + services.auth = Arc::new((*services.auth).clone().with_settings(settings.clone())); + services.webhook = Arc::new((*services.webhook).clone().with_settings(settings.clone())); let backup_destination = crate::services::backup::build_backup_destination(&config) .map_err(|e| format!("Failed to initialize backup destination: {e}"))?; - let backup_service = Arc::new(crate::services::backup::BackupService::new( - pool.clone(), - storage_registry.clone(), - backup_destination, - &config, - )); + let backup_service = Arc::new( + crate::services::backup::BackupService::new( + pool.clone(), + storage_registry.clone(), + backup_destination, + &config, + ) + .with_settings(settings.clone()), + ); let app = create_router( pool.clone(), @@ -83,6 +90,7 @@ pub async fn run( storage_registry.clone(), services.clone(), backup_service.clone(), + settings, ); // Reconcile backups/restore jobs left mid-flight by a previous process: any @@ -93,13 +101,11 @@ pub async fn run( Err(e) => tracing::error!("Failed to reconcile interrupted backups: {e}"), } - if config.backup_enabled { - let scheduler_service = backup_service.clone(); - tokio::spawn(async move { - crate::services::backup::scheduler::run(scheduler_service).await; - }); - info!("Backup scheduler started"); - } + let scheduler_service = backup_service.clone(); + tokio::spawn(async move { + crate::services::backup::scheduler::run(scheduler_service).await; + }); + info!("Backup scheduler started"); // The search indexer is the single writer/consumer: it rebuilds the index when // empty (first run / wiped), then drains the cross-process queue forever. @@ -120,7 +126,7 @@ pub async fn run( let addr: SocketAddr = config .bind_address .parse() - .map_err(|e| format!("Invalid BIND_ADDRESS '{}': {e}", config.bind_address))?; + .map_err(|e| format!("Invalid server.http_address '{}': {e}", config.bind_address))?; info!("Dashboard UI available at http://{}/dashboard", addr); info!("REST API server running on http://{}", addr); info!("GraphQL endpoint at http://{}/api/graphql", addr); @@ -131,9 +137,35 @@ pub async fn run( let grpc_addr: SocketAddr = config .grpc_bind_address .parse() - .map_err(|e| format!("Invalid GRPC_BIND_ADDRESS '{}': {e}", config.grpc_bind_address))?; + .map_err(|e| format!("Invalid server.grpc_address '{}': {e}", config.grpc_bind_address))?; info!("gRPC server running on {}", grpc_addr); + if runtime.mode == crate::paths::RuntimeMode::Portable { + let mcp_line = if config.mcp_enabled { + format!(" MCP http://{addr}/mcp\n") + } else { + String::new() + }; + println!( + "\nVelopulent CMS\n\ + Mode portable\n\ + Data {}\n\ + Dashboard http://{}/dashboard\n\ + REST http://{}/api/v1\n\ + GraphQL http://{}/api/graphql\n\ + gRPC {}\n\ + {}\ + Logs {}\n", + runtime.paths.root().display(), + addr, + addr, + addr, + grpc_addr, + mcp_line, + runtime.paths.logs_dir().display(), + ); + } + // Bind both listeners *before* declaring readiness (and before the serve loops // spawn): a bind failure — the classic "port already taken" — must surface as a // startup error, not as a background task that dies after we claimed to be up. @@ -242,13 +274,13 @@ pub async fn shutdown_signal() { /// Register the configured storage backends (filesystem and/or S3). pub fn initialize_storage(config: &Config) -> Arc { - let mut storage_registry = StorageRegistry::new(); + let storage_registry = StorageRegistry::new(); // Use an explicit filesystem path if set; otherwise default to the data dir's // storage/ so uploads work out of the box — unless S3 is configured and takes over. let fs_path = match (&config.storage_fs_path, config.has_s3()) { (Some(path), _) => Some(path.clone()), - (None, false) => Some(crate::paths::storage_dir().to_string_lossy().into_owned()), + (None, false) => None, (None, true) => None, }; @@ -280,7 +312,7 @@ pub fn initialize_storage(config: &Config) -> Arc { } if storage_registry.get(STORAGE_KIND_FILESYSTEM).is_none() && storage_registry.get(STORAGE_KIND_S3).is_none() { - warn!("No storage providers configured. Set STORAGE_FS_PATH or S3_* env vars."); + warn!("No storage providers configured"); } Arc::new(storage_registry) diff --git a/apps/backend/src/service/mod.rs b/apps/backend/src/service/mod.rs index 6f77067b..9322b3fe 100644 --- a/apps/backend/src/service/mod.rs +++ b/apps/backend/src/service/mod.rs @@ -14,7 +14,25 @@ mod windows; pub async fn run_service(action: &ServiceAction, _cli: &Cli) -> Result<(), Box> { match action { ServiceAction::Status => status::print(), - #[cfg(windows)] - ServiceAction::Run => windows::dispatch(action, _cli), + ServiceAction::Run => { + #[cfg(windows)] + { + windows::dispatch(action, _cli) + } + #[cfg(not(windows))] + { + let context = crate::runtime::RuntimeContext::initialize(crate::paths::RuntimeMode::Installed)?; + crate::server::run(context, crate::server::shutdown_signal(), || {}).await + } + } } } + +pub fn is_installed() -> Result> { + status::is_installed() +} + +#[cfg(windows)] +pub fn run_service_sync(action: &ServiceAction, cli: &Cli) -> Result<(), Box> { + windows::dispatch(action, cli) +} diff --git a/apps/backend/src/service/status.rs b/apps/backend/src/service/status.rs index 96bd74af..8e0c52b6 100644 --- a/apps/backend/src/service/status.rs +++ b/apps/backend/src/service/status.rs @@ -24,6 +24,56 @@ pub fn print() -> Result<(), Box> { Ok(()) } +/// Detect registration, not runtime state. Data-directory existence is never used. +pub fn is_installed() -> Result> { + #[cfg(target_os = "linux")] + { + if [ + "/etc/systemd/system/vcms.service", + "/lib/systemd/system/vcms.service", + "/usr/lib/systemd/system/vcms.service", + ] + .iter() + .any(|path| std::path::Path::new(path).is_file()) + { + return Ok(true); + } + } + #[cfg(target_os = "macos")] + { + let path = std::path::Path::new("/Library/LaunchDaemons/com.velopulent.vcms.plist"); + return match std::fs::metadata(path) { + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + }; + } + + let (manager, mut command) = native_command(); + let output = command + .output() + .map_err(|error| format!("cannot query {manager} service registration: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let text = format!("{stdout} {stderr}").to_ascii_lowercase(); + if text.contains("could not be found") + || text.contains("not-found") + || text.contains("no such process") + || text.contains("failed to get unit file state") + || text.contains("1060") + { + return Ok(false); + } + if output.status.success() || text.contains("loaded") || text.contains("running") || text.contains("stopped") { + return Ok(true); + } + Err(format!( + "{manager} could not determine whether vcms is installed: {}", + stderr.trim() + ) + .into()) +} + fn normalized_state(success: bool, stdout: &str, stderr: &str) -> &'static str { let text = format!("{stdout} {stderr}").to_ascii_lowercase(); if text.contains("running") || text.trim() == "active" { diff --git a/apps/backend/src/service/windows.rs b/apps/backend/src/service/windows.rs index be2c563b..2bf5df34 100644 --- a/apps/backend/src/service/windows.rs +++ b/apps/backend/src/service/windows.rs @@ -21,11 +21,9 @@ use windows_service::{define_windows_service, service_dispatcher}; use super::SERVICE_NAME; use crate::cli::{Cli, ServiceAction}; -/// Fixed home for the LocalSystem service (Windows convention is ProgramData). -/// The service host sets this before runtime startup so direct SCM launches always -/// use the machine-wide data directory. +/// Fixed home for the native service (Windows convention is ProgramData). fn service_home() -> std::path::PathBuf { - crate::paths::system_home().expect("system_home is always set on Windows") + crate::paths::system_root().expect("system_root is always set on Windows") } pub fn dispatch(action: &ServiceAction, _cli: &Cli) -> Result<(), Box> { @@ -39,17 +37,6 @@ define_windows_service!(ffi_service_main, service_main); /// Entry point for `vcms service run`: hand the thread to the SCM dispatcher. fn run_dispatcher() -> Result<(), Box> { - // Pin the service to the ProgramData home so it doesn't fall back to the - // LocalSystem profile. Set before any thread/runtime reads the environment. - if std::env::var_os(crate::paths::CMS_HOME_ENV).is_none() { - // SAFETY: called once at process start, before tokio/threads spin up. - unsafe { - std::env::set_var(crate::paths::CMS_HOME_ENV, service_home()); - } - } - // A service has no console, so the default stdout logging goes nowhere. Default - // to file output (lands in the home's logs/ — where install tells the user to - // look); an explicit LOG_OUTPUT (env or .env) still wins. service_dispatcher::start(SERVICE_NAME, ffi_service_main)?; Ok(()) } @@ -98,18 +85,18 @@ fn run_service_session() -> Result<(), Box> { let status_handle = service_control_handler::register(SERVICE_NAME, move |control| match control { ServiceControl::Stop | ServiceControl::Shutdown => { - if let Ok(guard) = handle_cell.lock() { - if let Some(h) = guard.as_ref() { - let _ = h.set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::StopPending, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 1, - wait_hint: Duration::from_secs(30), - process_id: None, - }); - } + if let Ok(guard) = handle_cell.lock() + && let Some(h) = guard.as_ref() + { + let _ = h.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::StopPending, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 1, + wait_hint: Duration::from_secs(30), + process_id: None, + }); } handler_notify.notify_one(); ServiceControlHandlerResult::NoError @@ -150,7 +137,8 @@ fn run_service_session() -> Result<(), Box> { // a fresh runtime here is safe — no nested-runtime panic. let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; let shutdown = async move { notify.notified().await }; - let result = runtime.block_on(crate::server::run(&Cli::default(), shutdown, on_ready)); + let context = crate::runtime::RuntimeContext::initialize(crate::paths::RuntimeMode::Installed)?; + let result = runtime.block_on(crate::server::run(context, shutdown, on_ready)); if let Err(e) = &result { log_service_error(&format!("server startup/run failed: {e}")); diff --git a/apps/backend/src/services/auth.rs b/apps/backend/src/services/auth.rs index 66946a5a..6adfa892 100644 --- a/apps/backend/src/services/auth.rs +++ b/apps/backend/src/services/auth.rs @@ -40,6 +40,7 @@ pub struct AuthService { session_lifetime_hours: i64, public_registration_enabled: bool, bcrypt_cost: u32, + settings: Option, } #[derive(Error, Debug)] @@ -108,16 +109,40 @@ impl AuthService { session_lifetime_hours, public_registration_enabled, bcrypt_cost, + settings: None, } } + pub fn with_settings(mut self, settings: crate::services::settings::SettingsService) -> Self { + self.settings = Some(settings); + self + } + + fn runtime_values(&self) -> (bool, i64, bool) { + self.settings.as_ref().map_or( + ( + self.cookie_secure, + self.session_lifetime_hours, + self.public_registration_enabled, + ), + |service| { + let settings = service.current(); + ( + settings.security.secure_cookies, + settings.general.session_lifetime_hours as i64, + settings.general.public_registration, + ) + }, + ) + } + pub async fn register(&self, name: &str, email: &str, password: &str) -> Result { let user_count = self .user_repo .count() .await .map_err(|e| AuthError::DatabaseError(e.to_string()))?; - if user_count > 0 && !self.public_registration_enabled { + if user_count > 0 && !self.runtime_values().2 { return Err(AuthError::RegistrationDisabled); } let name = name.trim(); @@ -204,8 +229,7 @@ impl AuthService { info!("Login successful for user: id={}, name={}", user.id, user.name); let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let csrf_token = Uuid::new_v4().to_string(); - let expires_at = - (chrono::Utc::now() + chrono::Duration::hours(self.session_lifetime_hours)).to_rfc3339(); + let expires_at = (chrono::Utc::now() + chrono::Duration::hours(self.runtime_values().1)).to_rfc3339(); self.session_repo .create( &Uuid::now_v7().to_string(), @@ -451,24 +475,25 @@ impl AuthService { } pub fn cookie_secure(&self) -> bool { - self.cookie_secure + self.runtime_values().0 } pub fn build_auth_cookies_response(&self, user: UserPublic, token: &str, csrf_token: &str) -> Response { + let (cookie_secure, session_lifetime_hours, _) = self.runtime_values(); let token_cookie = Cookie::build(("token", token.to_string())) .http_only(true) - .secure(self.cookie_secure) + .secure(cookie_secure) .same_site(axum_extra::extract::cookie::SameSite::Strict) .path("/") - .max_age(Duration::hours(self.session_lifetime_hours)) + .max_age(Duration::hours(session_lifetime_hours)) .build(); let csrf_cookie = Cookie::build(("csrf", csrf_token.to_string())) .http_only(false) - .secure(self.cookie_secure) + .secure(cookie_secure) .same_site(axum_extra::extract::cookie::SameSite::Strict) .path("/") - .max_age(Duration::hours(self.session_lifetime_hours)) + .max_age(Duration::hours(session_lifetime_hours)) .build(); let mut response = (StatusCode::OK, Json(AuthResponse { user })).into_response(); diff --git a/apps/backend/src/services/backup/mod.rs b/apps/backend/src/services/backup/mod.rs index 051bfb9d..84f07e12 100644 --- a/apps/backend/src/services/backup/mod.rs +++ b/apps/backend/src/services/backup/mod.rs @@ -14,9 +14,11 @@ pub mod schema; use std::collections::{HashMap, HashSet}; use std::io::Read; use std::sync::Arc; +use std::sync::RwLock; use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::Engine; use chrono::Utc; use rand::Rng; use serde::{Deserialize, Serialize}; @@ -105,6 +107,13 @@ pub struct Manifest { pub compression: String, pub tables: Vec, pub files: Vec, + #[serde(default)] + pub recovery_required: Vec, +} + +#[derive(Serialize)] +pub struct RecoveryReport { + pub recovery_required: Vec, } // --- Request/options types -------------------------------------------------- @@ -155,28 +164,106 @@ pub struct RestoreRequest { pub struct BackupService { pool: DbPool, storage: Arc, - destination: Arc, + destination: Arc>, + filesystem_destination: Option, encryption_key: Option<[u8; 32]>, zstd_level: i32, + settings: Option, } -impl BackupService { - pub fn new( - pool: DbPool, - storage: Arc, - destination: Arc, - config: &Config, +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "provider", rename_all = "lowercase")] +enum DestinationSpec { + Filesystem { + path: String, + }, + S3 { + access_key: String, + secret_key: String, + bucket: String, + region: String, + endpoint: Option, + public_url: Option, + }, +} + +#[derive(Clone)] +pub struct BackupDestination { + provider: Arc, + spec: DestinationSpec, +} + +impl BackupDestination { + pub fn filesystem(path: String) -> Result { + let provider = FileSystemStorage::new(&path).map_err(|error| BackupError::Storage(error.to_string()))?; + Ok(Self { + provider: Arc::new(provider), + spec: DestinationSpec::Filesystem { path }, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn s3( + provider: Arc, + access_key: String, + secret_key: String, + bucket: String, + region: String, + endpoint: Option, + public_url: Option, ) -> Self { + Self { + provider, + spec: DestinationSpec::S3 { + access_key, + secret_key, + bucket, + region, + endpoint, + public_url, + }, + } + } +} + +impl BackupService { + pub fn new(pool: DbPool, storage: Arc, destination: BackupDestination, config: &Config) -> Self { let encryption_key = config.backup_encryption_key.as_deref().and_then(parse_key_hex); + let filesystem_destination = config + .backup_local_path + .clone() + .and_then(|path| BackupDestination::filesystem(path).ok()); Self { pool, storage, - destination, + destination: Arc::new(RwLock::new(destination)), + filesystem_destination, encryption_key, zstd_level: config.backup_zstd_level, + settings: None, } } + pub fn with_settings(mut self, settings: crate::services::settings::SettingsService) -> Self { + self.settings = Some(settings); + self + } + + pub fn scheduler_enabled(&self) -> bool { + self.settings + .as_ref() + .map(|service| service.current().backups.enabled) + .unwrap_or(true) + } + + pub fn set_destination(&self, destination: BackupDestination) { + *self.destination.write().expect("backup destination poisoned") = destination; + } + + fn destination_snapshot(&self) -> BackupDestination { + self.destination.read().expect("backup destination poisoned").clone() + } + pub fn pool(&self) -> &DbPool { &self.pool } @@ -228,6 +315,11 @@ impl BackupService { compression: "zstd".to_string(), tables: table_manifest, files: file_manifest, + recovery_required: vec![ + "Recreate API access tokens".into(), + "Review disabled webhooks and restore secret headers".into(), + "Reconfigure storage and backup credentials when using S3".into(), + ], }; // tar + zstd + AES-GCM are CPU-bound; keep them off the async runtime @@ -306,11 +398,13 @@ impl BackupService { Scope::Instance => format!("instance/{id}.cmsbak"), Scope::Site(sid) => format!("site/{sid}/{id}.cmsbak"), }; - self.destination + let destination = self.destination_snapshot(); + destination + .provider .put(&key, bytes::Bytes::from(bytes.clone()), "application/octet-stream") .await .map_err(|e| BackupError::Storage(e.to_string()))?; - Ok((bytes, manifest, key)) + Ok((bytes, manifest, self.encode_destination(&destination.spec, &key)?)) } /// Delete a backup artifact and its row. @@ -318,7 +412,7 @@ impl BackupService { if let Some(row) = meta::get_backup(&self.pool, id).await? && let Some(key) = &row.destination_key { - let _ = self.destination.delete(key).await; + let _ = self.delete_destination_object(key).await; } meta::delete_backup_row(&self.pool, id).await } @@ -420,31 +514,105 @@ impl BackupService { /// [`delete_destination`](Self::delete_destination) after the restore. pub async fn stage_upload(&self, bytes: Vec) -> Result { let key = format!("{TEMP_RESTORE_PREFIX}{}.cmsbak", new_id()); - self.destination + let destination = self.destination_snapshot(); + destination + .provider .put(&key, bytes::Bytes::from(bytes), "application/octet-stream") .await .map_err(|e| BackupError::Storage(e.to_string()))?; - Ok(key) + self.encode_destination(&destination.spec, &key) } /// Best-effort delete of a destination object (used to clean up staged uploads). pub async fn delete_destination(&self, key: &str) { - let _ = self.destination.delete(key).await; + let _ = self.delete_destination_object(key).await; } /// Read a backup artifact from the configured destination. pub async fn read_destination(&self, key: &str) -> Result, BackupError> { - let bytes = self - .destination - .get(key) + let (provider, object_key) = self.resolve_destination(key)?; + let bytes = provider + .get(&object_key) .await .map_err(|e| BackupError::Storage(e.to_string()))?; Ok(bytes.to_vec()) } + fn encode_destination(&self, spec: &DestinationSpec, key: &str) -> Result { + let encryption_key = self.encryption_key.ok_or(BackupError::MissingKey)?; + let plaintext = serde_json::to_vec(&(spec, key)).map_err(|error| BackupError::Io(error.to_string()))?; + let cipher = Aes256Gcm::new(&Key::::from(encryption_key)); + let mut nonce_bytes = [0_u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from(nonce_bytes); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .map_err(|_| BackupError::Crypto("destination encryption failed".into()))?; + let mut encoded = nonce_bytes.to_vec(); + encoded.extend(ciphertext); + Ok(format!( + "dest:v1:{}", + base64::engine::general_purpose::STANDARD.encode(encoded) + )) + } + + fn resolve_destination(&self, locator: &str) -> Result<(Arc, String), BackupError> { + let encoded = locator + .strip_prefix("dest:v1:") + .ok_or_else(|| BackupError::Invalid("invalid backup destination locator".into()))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| BackupError::Invalid(error.to_string()))?; + if bytes.len() < 13 { + return Err(BackupError::Invalid("backup destination locator is truncated".into())); + } + let encryption_key = self.encryption_key.ok_or(BackupError::MissingKey)?; + let cipher = Aes256Gcm::new(&Key::::from(encryption_key)); + let nonce_bytes: [u8; 12] = bytes[..12] + .try_into() + .map_err(|_| BackupError::Invalid("backup destination nonce is invalid".into()))?; + let plaintext = cipher + .decrypt(&Nonce::from(nonce_bytes), &bytes[12..]) + .map_err(|_| BackupError::Crypto("backup destination locator cannot be decrypted".into()))?; + let (spec, key): (DestinationSpec, String) = + serde_json::from_slice(&plaintext).map_err(|error| BackupError::Invalid(error.to_string()))?; + let provider: Arc = match spec { + DestinationSpec::Filesystem { path } => { + Arc::new(FileSystemStorage::new(&path).map_err(|error| BackupError::Storage(error.to_string()))?) + } + DestinationSpec::S3 { + access_key, + secret_key, + bucket, + region, + endpoint, + public_url, + } => Arc::new( + S3Storage::new( + &access_key, + &secret_key, + &bucket, + ®ion, + endpoint.as_deref(), + public_url.as_deref(), + ) + .map_err(|error| BackupError::Storage(error.to_string()))?, + ), + }; + Ok((provider, key)) + } + + async fn delete_destination_object(&self, locator: &str) -> Result<(), BackupError> { + let (provider, key) = self.resolve_destination(locator)?; + provider + .delete(&key) + .await + .map_err(|error| BackupError::Storage(error.to_string())) + } + /// Restore from a backup artifact, replacing all data within the chosen scope /// in a single transaction. - pub async fn restore(&self, req: RestoreRequest) -> Result<(), BackupError> { + pub async fn restore(&self, req: RestoreRequest) -> Result { let bytes = match &req.source { RestoreSource::Bytes(b) => b.clone(), RestoreSource::Destination(key) => self.read_destination(key).await?, @@ -467,6 +635,9 @@ impl BackupService { } let tables = parse_all_tables(&tables_ndjson)?; + if matches!(&req.target, RestoreTarget::WholeInstance) { + validate_restored_settings(&tables)?; + } let plan = match &req.target { RestoreTarget::WholeInstance => { @@ -495,9 +666,28 @@ impl BackupService { schema::apply_restore(&self.pool, &plan).await?; + // Credential blobs are intentionally absent from backups. Drop any old + // process-held providers before publishing restored non-secret settings. + if matches!(&req.target, RestoreTarget::WholeInstance) { + self.storage.remove("s3"); + if let Some(destination) = &self.filesystem_destination { + self.set_destination(destination.clone()); + } + } + let mut recovery_required = manifest.recovery_required.clone(); + if matches!(&req.target, RestoreTarget::WholeInstance) + && let Some(settings) = &self.settings + && let Err(error) = settings.reload().await + { + tracing::error!(%error, "restore committed but runtime settings reload failed"); + recovery_required.push(format!( + "Restore committed, but runtime settings could not be reloaded: {error}. Restart the service." + )); + } + // Restore file blobs (best-effort: a missing blob is non-fatal). self.restore_files(&manifest, &file_blobs).await; - Ok(()) + Ok(RecoveryReport { recovery_required }) } /// Build a merged, atomic restore plan for one or more sites extracted from the @@ -661,7 +851,7 @@ impl BackupService { /// Build the backup destination storage provider from config (independent of the /// site-files storage). Falls back to a local `backups/` directory. -pub fn build_backup_destination(config: &Config) -> Result, BackupError> { +pub fn build_backup_destination(config: &Config) -> Result { if config.backup_destination == "s3" && config.has_backup_s3() { let access_key = config .backup_s3_access_key_id @@ -684,20 +874,31 @@ pub fn build_backup_destination(config: &Config) -> Result schema::RestorePlan { let deletes = vec![ + schema::Statement { + sql: "DELETE FROM instance_settings".into(), + rows: vec![], + }, schema::Statement { sql: "DELETE FROM sites".into(), rows: vec![], @@ -769,6 +970,19 @@ fn inserts_for(backend: crate::database::backend::DatabaseBackend, tables: &Tabl type Row = serde_json::Map; type Tables = HashMap>; +fn validate_restored_settings(tables: &Tables) -> Result<(), BackupError> { + let row = tables + .get("instance_settings") + .and_then(|rows| rows.first()) + .ok_or_else(|| BackupError::Invalid("instance backup is missing instance settings".into()))?; + let json = str_field(row, "settings_json") + .ok_or_else(|| BackupError::Invalid("restored instance settings are missing settings_json".into()))?; + let settings: crate::services::settings::InstanceSettings = serde_json::from_str(json) + .map_err(|error| BackupError::Invalid(format!("invalid restored settings: {error}")))?; + crate::services::settings::validate(&settings) + .map_err(|error| BackupError::Invalid(format!("invalid restored settings: {error}"))) +} + /// A decoded backup artifact: the manifest, the per-table NDJSON bytes (keyed by /// table name), and the uploaded file blobs (keyed by storage key). type ArtifactBundle = (Manifest, HashMap>, HashMap>); @@ -1092,3 +1306,42 @@ fn parse_key_hex(hex_str: &str) -> Option<[u8; 32]> { key.copy_from_slice(&bytes); Some(key) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn staged_artifact_remains_bound_to_original_destination() { + let pool = crate::database::init_db("sqlite::memory:").await.unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let mut config = Config { + backup_local_path: Some(first.path().to_string_lossy().into_owned()), + ..Config::default() + }; + let service = BackupService::new( + pool, + Arc::new(StorageRegistry::new()), + build_backup_destination(&config).unwrap(), + &config, + ); + + let locator = service.stage_upload(b"bound-to-first".to_vec()).await.unwrap(); + config.backup_local_path = Some(second.path().to_string_lossy().into_owned()); + service.set_destination(build_backup_destination(&config).unwrap()); + + assert_eq!(service.read_destination(&locator).await.unwrap(), b"bound-to-first"); + service.delete_destination(&locator).await; + assert!(service.read_destination(&locator).await.is_err()); + } + + #[test] + fn whole_instance_restore_rejects_invalid_settings_before_commit() { + let mut row = Row::new(); + row.insert("settings_json".into(), serde_json::Value::String("{}".into())); + let mut tables = Tables::new(); + tables.insert("instance_settings".into(), vec![row]); + assert!(validate_restored_settings(&tables).is_err()); + } +} diff --git a/apps/backend/src/services/backup/scheduler.rs b/apps/backend/src/services/backup/scheduler.rs index 58442456..96bdfedb 100644 --- a/apps/backend/src/services/backup/scheduler.rs +++ b/apps/backend/src/services/backup/scheduler.rs @@ -17,6 +17,9 @@ pub async fn run(service: Arc) { let mut ticker = tokio::time::interval(POLL_INTERVAL); loop { ticker.tick().await; + if !service.scheduler_enabled() { + continue; + } if let Err(e) = tick(&service).await { tracing::error!(error = %e, "backup scheduler tick failed"); } diff --git a/apps/backend/src/services/backup/schema.rs b/apps/backend/src/services/backup/schema.rs index d9061ff1..0597718d 100644 --- a/apps/backend/src/services/backup/schema.rs +++ b/apps/backend/src/services/backup/schema.rs @@ -56,6 +56,16 @@ use ColType::{Bool, Int, Json, Text, Timestamp}; /// All dumped tables, in FK parent→child order (the order restore inserts in). pub static TABLES: &[TableSpec] = &[ + TableSpec { + name: "instance_settings", + site_where: SiteWhere::InstanceOnly, + columns: &[ + col("id", Int), + col("version", Int), + col("settings_json", Text), + col("updated_at", Timestamp), + ], + }, TableSpec { name: "users", site_where: SiteWhere::InstanceOnly, @@ -161,24 +171,6 @@ pub static TABLES: &[TableSpec] = &[ col("change_summary", Text), ], }, - TableSpec { - name: "access_tokens", - site_where: SiteWhere::SiteId, - columns: &[ - col("id", Text), - col("site_id", Text), - col("name", Text), - col("token_hash", Text), - col("token_prefix", Text), - col("token_hmac", Text), - col("permission", Text), - col("created_by_user_id", Text), - col("last_used_at", Timestamp), - col("created_at", Timestamp), - col("expires_at", Timestamp), - col("revoked_at", Timestamp), - ], - }, TableSpec { name: "site_webhooks", site_where: SiteWhere::SiteId, @@ -188,6 +180,7 @@ pub static TABLES: &[TableSpec] = &[ col("label", Text), col("url", Text), col("headers_encrypted", Text), + col("enabled", Bool), col("created_by", Text), col("created_at", Timestamp), col("updated_at", Timestamp), @@ -285,7 +278,13 @@ pub async fn dump_tables(pool: &DbPool, scope: &Scope) -> Result Result { pub created_by: Option<&'a str>, pub storage: Arc, pub storage_provider: &'a str, + pub max_bytes: usize, } /// RAII claim on an in-flight pre-generated file id; released on drop. @@ -204,6 +205,7 @@ impl FileService { created_by, storage, storage_provider, + max_bytes: self.config.max_upload_size_bytes, }, stream, ) @@ -231,6 +233,7 @@ impl FileService { created_by, storage, storage_provider, + max_bytes, } = req; info!( @@ -279,7 +282,7 @@ impl FileService { }; let storage_key = format!("s_{}/f_{}/{}", site_id, file_id, generated_filename); let mime_type = content_type.to_string(); - let max = self.config.max_upload_size_bytes; + let max = max_bytes; let upload = storage.start_multipart(&storage_key).await.map_err(|e| { error!("Failed to start multipart upload: key={}, error={}", storage_key, e); @@ -877,6 +880,7 @@ mod tests { created_by: None, storage, storage_provider: "filesystem", + max_bytes: 1024, }; let first = service diff --git a/apps/backend/src/services/mod.rs b/apps/backend/src/services/mod.rs index 86908277..50e79fb3 100644 --- a/apps/backend/src/services/mod.rs +++ b/apps/backend/src/services/mod.rs @@ -8,6 +8,7 @@ pub mod entry; pub mod error; pub mod file; pub mod search; +pub mod settings; pub mod singleton; pub mod site; pub mod webhook; @@ -76,7 +77,7 @@ impl Services { auth: Arc::new(auth::AuthService::new( repository.user.clone(), repository.session.clone(), - config.hmac_secret.clone(), + config.session_auth_key.clone(), config.cookie_secure, config.session_lifetime_hours, config.public_registration_enabled, @@ -85,7 +86,7 @@ impl Services { site: Arc::new(site::SiteService::new(repository.site.clone(), repository.user.clone())), access_token: Arc::new(access_token::AccessTokenService::new( repository.access_token.clone(), - config.hmac_secret.clone(), + config.token_index_key.clone(), config.bcrypt_cost, )), collection: Arc::new(collection::CollectionService::new( @@ -112,7 +113,7 @@ impl Services { ), webhook: Arc::new(webhook::WebhookService::new( repository.webhook.clone(), - &config.hmac_secret, + &config.webhook_encryption_key, config.webhook_allow_private_targets, )), search, @@ -126,12 +127,8 @@ enum IndexAccess { ReadOnly, } -fn search_index_path(config: &Config) -> PathBuf { - config - .search_index_path - .clone() - .map(PathBuf::from) - .unwrap_or_else(crate::paths::search_dir) +fn search_index_path(config: &Config) -> Option { + config.search_index_path.clone().map(PathBuf::from) } /// Open the search index for queries. Returns `None` when search is disabled or the @@ -142,7 +139,10 @@ fn build_search(config: &Config, access: IndexAccess) -> Option SearchService::open(&path), IndexAccess::ReadOnly => SearchService::open_read_only(&path), diff --git a/apps/backend/src/services/settings.rs b/apps/backend/src/services/settings.rs new file mode 100644 index 00000000..95d94fc4 --- /dev/null +++ b/apps/backend/src/services/settings.rs @@ -0,0 +1,494 @@ +use std::sync::Arc; + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, RwLock, watch}; + +use crate::database::pool::DbPool; + +pub const SETTINGS_VERSION: u32 = 1; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct InstanceSettings { + pub version: u32, + pub general: GeneralSettings, + pub security: SecuritySettings, + pub storage: StorageSettings, + pub backups: BackupSettings, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct GeneralSettings { + pub public_url: Option, + pub public_registration: bool, + pub session_lifetime_hours: u64, + pub upload_limit_mb: usize, + pub mcp_enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SecuritySettings { + pub secure_cookies: bool, + pub allowed_origins: Vec, + pub trusted_proxy_headers: bool, + pub private_webhook_targets: bool, + pub mcp_allowed_hosts: Vec, + pub mcp_allowed_origins: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct StorageSettings { + pub provider: String, + pub bucket: Option, + pub region: Option, + pub endpoint: Option, + pub public_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BackupSettings { + pub enabled: bool, + pub destination: String, + pub retention: u32, + pub bucket: Option, + pub region: Option, + pub endpoint: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CredentialPair { + pub access_key_id: String, + pub secret_access_key: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EncryptedCredentials { + pub storage: Option, + pub backups: Option, +} + +impl Default for InstanceSettings { + fn default() -> Self { + Self { + version: SETTINGS_VERSION, + general: GeneralSettings { + public_url: None, + public_registration: false, + session_lifetime_hours: 24, + upload_limit_mb: 50, + mcp_enabled: true, + }, + security: SecuritySettings { + secure_cookies: false, + allowed_origins: Vec::new(), + trusted_proxy_headers: false, + private_webhook_targets: false, + mcp_allowed_hosts: vec!["localhost".into(), "127.0.0.1".into()], + mcp_allowed_origins: Vec::new(), + }, + storage: StorageSettings { + provider: "filesystem".into(), + bucket: None, + region: Some("us-east-1".into()), + endpoint: None, + public_url: None, + }, + backups: BackupSettings { + enabled: true, + destination: "filesystem".into(), + retention: 7, + bucket: None, + region: Some("us-east-1".into()), + endpoint: None, + }, + } + } +} + +#[derive(Clone)] +pub struct SettingsService { + pool: DbPool, + key: Arc<[u8; 32]>, + snapshot: watch::Sender>, + credentials: Arc>, + write_lock: Arc>, +} + +impl SettingsService { + pub async fn load(pool: DbPool, master_key: &str) -> Result { + let key = decode_key(&crate::secrets::derive_key_hex( + master_key, + "instance-settings-encryption", + ))?; + let row = load_row(&pool).await.map_err(|error| error.to_string())?; + let (settings, credentials) = match row { + Some((version, settings_json, encrypted)) => { + if version != SETTINGS_VERSION as i64 { + return Err(format!("unsupported instance settings version {version}")); + } + let settings: InstanceSettings = + serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + validate(&settings)?; + let credentials = match encrypted.as_deref() { + Some(value) => decrypt_credentials(&key, value).unwrap_or_else(|error| { + tracing::error!( + "Encrypted integration credentials are unusable; integrations disabled: {error}" + ); + EncryptedCredentials::default() + }), + None => EncryptedCredentials::default(), + }; + (settings, credentials) + } + None => { + let settings = InstanceSettings::default(); + persist(&pool, &settings, None) + .await + .map_err(|error| error.to_string())?; + (settings, EncryptedCredentials::default()) + } + }; + let (snapshot, _) = watch::channel(Arc::new(settings)); + Ok(Self { + pool, + key: Arc::new(key), + snapshot, + credentials: Arc::new(RwLock::new(credentials)), + write_lock: Arc::new(Mutex::new(())), + }) + } + + pub fn current(&self) -> Arc { + self.snapshot.borrow().clone() + } + + pub fn subscribe(&self) -> watch::Receiver> { + self.snapshot.subscribe() + } + + pub async fn credentials(&self) -> EncryptedCredentials { + self.credentials.read().await.clone() + } + + pub async fn publish( + &self, + expected: &InstanceSettings, + expected_credentials: &EncryptedCredentials, + settings: InstanceSettings, + credentials: EncryptedCredentials, + ) -> Result<(), String> { + let _guard = self.write_lock.lock().await; + validate(&settings)?; + let Some((version, settings_json, encrypted)) = + load_row(&self.pool).await.map_err(|error| error.to_string())? + else { + return Err("instance settings row is missing".into()); + }; + if version != SETTINGS_VERSION as i64 { + return Err(format!("unsupported instance settings version {version}")); + } + let persisted: InstanceSettings = serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + let persisted_credentials = match encrypted.as_deref() { + Some(value) => decrypt_credentials(&self.key, value)?, + None => EncryptedCredentials::default(), + }; + if &persisted != expected || &persisted_credentials != expected_credentials { + return Err("instance settings changed; reload and retry".into()); + } + let encrypted = if credentials.storage.is_some() || credentials.backups.is_some() { + Some(encrypt_credentials(&self.key, &credentials)?) + } else { + None + }; + persist(&self.pool, &settings, encrypted.as_deref()) + .await + .map_err(|error| error.to_string())?; + *self.credentials.write().await = credentials; + self.snapshot.send_replace(Arc::new(settings)); + Ok(()) + } + + pub async fn reload(&self) -> Result<(), String> { + let _guard = self.write_lock.lock().await; + let Some((version, settings_json, encrypted)) = + load_row(&self.pool).await.map_err(|error| error.to_string())? + else { + return Err("instance settings row is missing after restore".into()); + }; + if version != SETTINGS_VERSION as i64 { + return Err(format!("unsupported instance settings version {version}")); + } + let settings: InstanceSettings = serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + validate(&settings)?; + let credentials = match encrypted.as_deref() { + Some(value) => decrypt_credentials(&self.key, value).unwrap_or_default(), + None => EncryptedCredentials::default(), + }; + *self.credentials.write().await = credentials; + self.snapshot.send_replace(Arc::new(settings)); + Ok(()) + } + + pub async fn apply_to_config(&self, config: &mut crate::config::Config) { + let settings = self.current(); + let credentials = self.credentials().await; + config.public_url = settings.general.public_url.clone(); + config.public_registration_enabled = settings.general.public_registration; + config.session_lifetime_hours = settings.general.session_lifetime_hours as i64; + config.max_upload_size_bytes = settings.general.upload_limit_mb * 1024 * 1024; + config.mcp_enabled = settings.general.mcp_enabled; + config.cookie_secure = settings.security.secure_cookies; + config.allowed_origins = settings.security.allowed_origins.clone(); + config.trust_proxy_headers = settings.security.trusted_proxy_headers; + config.webhook_allow_private_targets = settings.security.private_webhook_targets; + config.mcp_allowed_hosts = settings.security.mcp_allowed_hosts.clone(); + config.mcp_allowed_origins = settings.security.mcp_allowed_origins.clone(); + + config.s3_bucket = settings.storage.bucket.clone(); + config.s3_region = settings.storage.region.clone(); + config.s3_endpoint = settings.storage.endpoint.clone(); + config.s3_public_url = settings.storage.public_url.clone(); + config.s3_access_key_id = credentials.storage.as_ref().map(|value| value.access_key_id.clone()); + config.s3_secret_access_key = credentials + .storage + .as_ref() + .map(|value| value.secret_access_key.clone()); + + config.backup_enabled = settings.backups.enabled; + config.backup_destination = settings.backups.destination.clone(); + config.backup_default_retention = settings.backups.retention as i64; + config.backup_s3_bucket = settings.backups.bucket.clone(); + config.backup_s3_region = settings.backups.region.clone(); + config.backup_s3_endpoint = settings.backups.endpoint.clone(); + config.backup_s3_access_key_id = credentials.backups.as_ref().map(|value| value.access_key_id.clone()); + config.backup_s3_secret_access_key = credentials + .backups + .as_ref() + .map(|value| value.secret_access_key.clone()); + } +} + +pub fn validate(settings: &InstanceSettings) -> Result<(), String> { + if settings.version != SETTINGS_VERSION { + return Err("settings version is not supported".into()); + } + if !(1..=168).contains(&settings.general.session_lifetime_hours) { + return Err("session_lifetime_hours must be between 1 and 168".into()); + } + if !(1..=1024).contains(&settings.general.upload_limit_mb) { + return Err("upload_limit_mb must be between 1 and 1024".into()); + } + if let Some(value) = settings.general.public_url.as_deref() { + validate_http_url(value, "public_url")?; + } + for origin in settings + .security + .allowed_origins + .iter() + .chain(&settings.security.mcp_allowed_origins) + { + let parsed = validate_http_url(origin, "allowed origin")?; + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err(format!( + "allowed origin must not contain a path, query, or fragment: {origin}" + )); + } + } + if settings + .security + .mcp_allowed_hosts + .iter() + .any(|value| value.trim().is_empty()) + { + return Err("MCP allowed hosts cannot contain empty values".into()); + } + if !matches!(settings.storage.provider.as_str(), "filesystem" | "s3") { + return Err("storage provider must be filesystem or s3".into()); + } + if !matches!(settings.backups.destination.as_str(), "filesystem" | "s3") { + return Err("backup destination must be filesystem or s3".into()); + } + if settings.storage.provider == "s3" && settings.storage.bucket.as_deref().is_none_or(str::is_empty) { + return Err("storage bucket is required for S3".into()); + } + if settings.backups.destination == "s3" && settings.backups.bucket.as_deref().is_none_or(str::is_empty) { + return Err("backup bucket is required for S3".into()); + } + if settings.backups.retention == 0 || settings.backups.retention > 10_000 { + return Err("backup retention must be between 1 and 10000".into()); + } + Ok(()) +} + +fn validate_http_url(value: &str, name: &str) -> Result { + let parsed = url::Url::parse(value).map_err(|error| format!("{name} is invalid: {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(format!("{name} must be an absolute HTTP(S) URL")); + } + Ok(parsed) +} + +async fn load_row(pool: &DbPool) -> Result)>, sqlx::Error> { + match pool { + DbPool::Postgres(pool) => { + sqlx::query_as( + "SELECT version::bigint, settings_json, credentials_encrypted FROM instance_settings WHERE id = 1", + ) + .fetch_optional(pool) + .await + } + DbPool::MySql(pool) => sqlx::query_as( + "SELECT CAST(version AS SIGNED), settings_json, credentials_encrypted FROM instance_settings WHERE id = 1", + ) + .fetch_optional(pool) + .await, + DbPool::Sqlite(pool) => { + sqlx::query_as("SELECT version, settings_json, credentials_encrypted FROM instance_settings WHERE id = 1") + .fetch_optional(pool) + .await + } + } +} + +async fn persist(pool: &DbPool, settings: &InstanceSettings, encrypted: Option<&str>) -> Result<(), sqlx::Error> { + let json = serde_json::to_string(settings).map_err(|error| sqlx::Error::Encode(error.into()))?; + match pool { + DbPool::Postgres(pool) => sqlx::query("INSERT INTO instance_settings (id, version, settings_json, credentials_encrypted) VALUES (1, $1, $2, $3) ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, settings_json = EXCLUDED.settings_json, credentials_encrypted = EXCLUDED.credentials_encrypted, updated_at = NOW()") + .bind(SETTINGS_VERSION as i32).bind(json).bind(encrypted).execute(pool).await.map(|_| ()), + DbPool::MySql(pool) => sqlx::query("INSERT INTO instance_settings (id, version, settings_json, credentials_encrypted) VALUES (1, ?, ?, ?) ON DUPLICATE KEY UPDATE version = VALUES(version), settings_json = VALUES(settings_json), credentials_encrypted = VALUES(credentials_encrypted)") + .bind(SETTINGS_VERSION).bind(json).bind(encrypted).execute(pool).await.map(|_| ()), + DbPool::Sqlite(pool) => sqlx::query("INSERT INTO instance_settings (id, version, settings_json, credentials_encrypted) VALUES (1, ?1, ?2, ?3) ON CONFLICT(id) DO UPDATE SET version = excluded.version, settings_json = excluded.settings_json, credentials_encrypted = excluded.credentials_encrypted, updated_at = datetime('now')") + .bind(SETTINGS_VERSION).bind(json).bind(encrypted).execute(pool).await.map(|_| ()), + } +} + +fn decode_key(value: &str) -> Result<[u8; 32], String> { + let bytes = hex::decode(value).map_err(|error| error.to_string())?; + bytes.try_into().map_err(|_| "derived key must be 32 bytes".into()) +} + +fn encrypt_credentials(key: &[u8; 32], credentials: &EncryptedCredentials) -> Result { + let cipher = Aes256Gcm::new(&Key::::from(*key)); + let mut nonce = [0_u8; 12]; + rand::rng().fill_bytes(&mut nonce); + let plain = serde_json::to_vec(credentials).map_err(|error| error.to_string())?; + let nonce_value = Nonce::from(nonce); + let cipher_text = cipher + .encrypt(&nonce_value, plain.as_ref()) + .map_err(|_| "credential encryption failed")?; + let mut envelope = nonce.to_vec(); + envelope.extend(cipher_text); + Ok(format!("v1:{}", BASE64_STANDARD.encode(envelope))) +} + +fn decrypt_credentials(key: &[u8; 32], envelope: &str) -> Result { + let encoded = envelope + .strip_prefix("v1:") + .ok_or("unsupported credential envelope version")?; + let bytes = BASE64_STANDARD.decode(encoded).map_err(|error| error.to_string())?; + if bytes.len() < 13 { + return Err("credential envelope is truncated".into()); + } + let cipher = Aes256Gcm::new(&Key::::from(*key)); + let nonce: [u8; 12] = bytes[..12].try_into().map_err(|_| "credential nonce is invalid")?; + let nonce_value = Nonce::from(nonce); + let plain = cipher + .decrypt(&nonce_value, &bytes[12..]) + .map_err(|_| "credential decryption failed")?; + serde_json::from_slice(&plain).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_limits_and_origins() { + let mut settings = InstanceSettings::default(); + settings.general.upload_limit_mb = 0; + assert!(validate(&settings).unwrap_err().contains("upload_limit_mb")); + settings.general.upload_limit_mb = 50; + settings.security.allowed_origins = vec!["https://example.com/path".into()]; + assert!(validate(&settings).unwrap_err().contains("must not contain a path")); + } + + #[test] + fn credential_envelope_is_versioned_and_authenticated() { + let key = [7_u8; 32]; + let credentials = EncryptedCredentials { + storage: Some(CredentialPair { + access_key_id: "AKIA_TEST".into(), + secret_access_key: "secret".into(), + }), + backups: None, + }; + let encrypted = encrypt_credentials(&key, &credentials).unwrap(); + assert!(encrypted.starts_with("v1:")); + assert!(!encrypted.contains("secret")); + assert_eq!( + decrypt_credentials(&key, &encrypted) + .unwrap() + .storage + .unwrap() + .access_key_id, + "AKIA_TEST" + ); + assert!(decrypt_credentials(&[8_u8; 32], &encrypted).is_err()); + } + + #[tokio::test] + async fn concurrent_publish_rejects_stale_snapshot() { + let pool = crate::database::init_db("sqlite::memory:").await.unwrap(); + let service = SettingsService::load(pool, &"11".repeat(32)).await.unwrap(); + let expected = service.current(); + let expected_credentials = service.credentials().await; + + let mut general_update = (*expected).clone(); + general_update.general.public_registration = true; + let mut security_update = (*expected).clone(); + security_update.security.private_webhook_targets = true; + + let first = service.clone(); + let first_expected = expected.clone(); + let first_credentials = expected_credentials.clone(); + let second = service.clone(); + let second_expected = expected.clone(); + let second_credentials = expected_credentials.clone(); + let (general_result, security_result) = tokio::join!( + async move { + first + .publish( + &first_expected, + &first_credentials, + general_update, + first_credentials.clone(), + ) + .await + }, + async move { + second + .publish( + &second_expected, + &second_credentials, + security_update, + second_credentials.clone(), + ) + .await + } + ); + + assert_ne!(general_result.is_ok(), security_result.is_ok()); + let stale = general_result.err().or_else(|| security_result.err()).unwrap(); + assert!(stale.contains("changed")); + } +} diff --git a/apps/backend/src/services/webhook.rs b/apps/backend/src/services/webhook.rs index 7094a1fd..48aa5a22 100644 --- a/apps/backend/src/services/webhook.rs +++ b/apps/backend/src/services/webhook.rs @@ -39,6 +39,7 @@ pub struct WebhookService { webhook_repo: Arc, encryption_key: Arc<[u8; 32]>, allow_private_targets: bool, + settings: Option, } #[derive(Error, Debug)] @@ -91,9 +92,22 @@ impl WebhookService { webhook_repo, encryption_key: Arc::new(key), allow_private_targets, + settings: None, } } + pub fn with_settings(mut self, settings: crate::services::settings::SettingsService) -> Self { + self.settings = Some(settings); + self + } + + fn allow_private_targets(&self) -> bool { + self.settings + .as_ref() + .map(|service| service.current().security.private_webhook_targets) + .unwrap_or(self.allow_private_targets) + } + pub async fn list_webhooks(&self, site_id: &str) -> Result, WebhookError> { self.webhook_repo .list_for_site(site_id) @@ -130,7 +144,7 @@ impl WebhookService { return Err(WebhookError::InvalidLabel("Label is required".into())); } - if let Err(e) = validate_url(url, self.allow_private_targets) { + if let Err(e) = validate_url(url, self.allow_private_targets()) { warn!( "Webhook creation failed: invalid url_pattern={}, error={}", sanitize_url_for_logging(url), @@ -181,7 +195,7 @@ impl WebhookService { ); if let Some(url_val) = url - && let Err(e) = validate_url(url_val, self.allow_private_targets) + && let Err(e) = validate_url(url_val, self.allow_private_targets()) { warn!( "Webhook update failed: invalid url_pattern={}, error={}", @@ -267,16 +281,23 @@ impl WebhookService { sanitize_url_for_logging(&webhook.url) ); - let headers = decrypt_headers(&webhook.headers_encrypted, &self.encryption_key); + let headers = decrypt_headers_checked(&webhook.headers_encrypted, &self.encryption_key).ok_or_else(|| { + WebhookError::DeliveryFailed("Webhook credentials cannot be decrypted; reconfigure this webhook".into()) + })?; debug!("Decrypted headers for webhook: header_count={}", headers.len()); // Re-validate and resolve right before sending: the stored host may now // resolve to a private address (DNS rebinding). Pin the connection to a // vetted IP and refuse redirects so it can't be bounced internally. - validate_url(&webhook.url, self.allow_private_targets)?; + if !webhook.enabled { + return Err(WebhookError::DeliveryFailed( + "Webhook is disabled and needs reconfiguration".into(), + )); + } + validate_url(&webhook.url, self.allow_private_targets())?; let parsed_url = url::Url::parse(&webhook.url).map_err(|_| WebhookError::InvalidUrl("Invalid URL format".into()))?; - let safe_addr = resolve_safe_addr(&parsed_url, self.allow_private_targets).await?; + let safe_addr = resolve_safe_addr(&parsed_url, self.allow_private_targets()).await?; let host = parsed_url.host_str().unwrap_or_default().to_string(); let client = reqwest::Client::builder() @@ -509,7 +530,7 @@ fn derive_encryption_key(secret: &str) -> [u8; 32] { } /// Encrypt webhook headers with AES-256-GCM. Output is -/// `base64(nonce[12] || ciphertext||tag)`. A fresh random nonce is used per call. +/// `v1:base64(nonce[12] || ciphertext||tag)`. A fresh random nonce is used per call. fn encrypt_headers(headers: &HashMap, key: &[u8; 32]) -> String { let json = serde_json::to_string(headers).unwrap_or_default(); let cipher = Aes256Gcm::new(&Key::::from(*key)); @@ -521,7 +542,7 @@ fn encrypt_headers(headers: &HashMap, key: &[u8; 32]) -> String let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ciphertext); - BASE64_STANDARD.encode(out) + format!("v1:{}", BASE64_STANDARD.encode(out)) } Err(_) => String::new(), } @@ -531,26 +552,33 @@ fn encrypt_headers(headers: &HashMap, key: &[u8; 32]) -> String /// (bad base64, wrong key, truncated data, or legacy/incompatible ciphertext) /// yields an empty map rather than an error. fn decrypt_headers(encrypted: &str, key: &[u8; 32]) -> HashMap { - let raw = match BASE64_STANDARD.decode(encrypted) { + decrypt_headers_checked(encrypted, key).unwrap_or_default() +} + +fn decrypt_headers_checked(encrypted: &str, key: &[u8; 32]) -> Option> { + if encrypted.is_empty() { + return Some(HashMap::new()); + } + let encoded = encrypted.strip_prefix("v1:")?; + let raw = match BASE64_STANDARD.decode(encoded) { Ok(b) => b, - Err(_) => return HashMap::new(), + Err(_) => return None, }; if raw.len() < 12 { - return HashMap::new(); + return None; } let (nonce_bytes, ciphertext) = raw.split_at(12); let cipher = Aes256Gcm::new(&Key::::from(*key)); let nonce_arr: [u8; 12] = match nonce_bytes.try_into() { Ok(n) => n, - Err(_) => return HashMap::new(), + Err(_) => return None, }; let nonce = Nonce::from(nonce_arr); match cipher.decrypt(&nonce, ciphertext) { - Ok(plaintext) => match String::from_utf8(plaintext) { - Ok(s) => serde_json::from_str(&s).unwrap_or_default(), - Err(_) => HashMap::new(), - }, - Err(_) => HashMap::new(), + Ok(plaintext) => String::from_utf8(plaintext) + .ok() + .and_then(|value| serde_json::from_str(&value).ok()), + Err(_) => None, } } @@ -585,6 +613,7 @@ mod tests { label: "Test Hook".to_string(), url: "https://example.com/hook".to_string(), headers_encrypted: String::new(), + enabled: true, created_by: None, created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), @@ -958,6 +987,7 @@ mod tests { label: "Hook".to_string(), url: "https://example.com".to_string(), headers_encrypted: encrypted, + enabled: true, created_by: None, created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), diff --git a/apps/backend/src/storage/mod.rs b/apps/backend/src/storage/mod.rs index 95143ac1..ee555660 100644 --- a/apps/backend/src/storage/mod.rs +++ b/apps/backend/src/storage/mod.rs @@ -4,7 +4,7 @@ pub mod s3; use async_trait::async_trait; use bytes::Bytes; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; pub use filesystem::FileSystemStorage; pub use s3::S3Storage; @@ -36,24 +36,34 @@ impl StorageKind { } } -#[derive(Clone)] pub struct StorageRegistry { - providers: HashMap>, + providers: RwLock>>, } impl StorageRegistry { pub fn new() -> Self { Self { - providers: HashMap::new(), + providers: RwLock::new(HashMap::new()), } } - pub fn register(&mut self, name: &str, provider: Arc) { - self.providers.insert(name.to_string(), provider); + pub fn register(&self, name: &str, provider: Arc) { + self.providers + .write() + .expect("storage registry poisoned") + .insert(name.to_string(), provider); + } + + pub fn remove(&self, name: &str) { + self.providers.write().expect("storage registry poisoned").remove(name); } pub fn get(&self, name: &str) -> Option> { - self.providers.get(name).cloned() + self.providers + .read() + .expect("storage registry poisoned") + .get(name) + .cloned() } pub fn get_by_kind(&self, kind: StorageKind) -> Option> { @@ -214,7 +224,7 @@ mod tests { #[test] fn test_storage_registry_register_and_get() { - let mut registry = StorageRegistry::new(); + let registry = StorageRegistry::new(); let storage: Arc = Arc::new(MockStorage::default()); registry.register("filesystem", storage.clone()); @@ -224,7 +234,7 @@ mod tests { #[test] fn test_storage_registry_get_by_kind() { - let mut registry = StorageRegistry::new(); + let registry = StorageRegistry::new(); let storage: Arc = Arc::new(MockStorage::default()); registry.register("filesystem", storage); diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index 810b9b30..63adb064 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -21,23 +20,6 @@ pub fn now_timestamp() -> String { chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() } -/// Synchronize tests that mutate `VCMS_HOME` (process-global env var). -/// The single static `Mutex` here is shared by all callers across modules, -/// preventing races between `paths::tests` and `secrets::tests` etc. -pub fn with_home(value: &Path, f: impl FnOnce() -> T) -> T { - static LOCK: Mutex<()> = Mutex::new(()); - let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let previous = std::env::var_os(crate::paths::CMS_HOME_ENV); - // SAFETY: guarded by LOCK so no other test reads/writes the env concurrently. - unsafe { std::env::set_var(crate::paths::CMS_HOME_ENV, value) }; - let result = f(); - match previous { - Some(v) => unsafe { std::env::set_var(crate::paths::CMS_HOME_ENV, v) }, - None => unsafe { std::env::remove_var(crate::paths::CMS_HOME_ENV) }, - } - result -} - #[derive(Clone)] pub struct InMemoryUserRepository { users: Arc>>, @@ -1271,6 +1253,7 @@ impl crate::repository::traits::WebhookRepository for InMemoryWebhookRepository label: label.to_string(), url: url.to_string(), headers_encrypted: headers_encrypted.to_string(), + enabled: true, created_by: created_by.map(|s| s.to_string()), created_at: now_timestamp(), updated_at: now_timestamp(), diff --git a/apps/backend/src/tracing.rs b/apps/backend/src/tracing.rs index 7fa34a06..e6943904 100644 --- a/apps/backend/src/tracing.rs +++ b/apps/backend/src/tracing.rs @@ -10,23 +10,15 @@ pub fn init_tracing(config: &crate::config::Config) -> Option { + match log_output { + "file" => { let log_dir = config.log_dir.as_str(); let file_appender = RollingFileAppender::new(Rotation::DAILY, log_dir, "cms.log"); let (file_writer, guard) = tracing_appender::non_blocking(file_appender); - let file_layer = fmt::layer() - .with_writer(file_writer) - .with_target(true) - .with_file(log_annotations) - .with_line_number(log_annotations) - .json(); + let file_layer = fmt::layer().with_writer(file_writer).with_target(true).json(); tracing_subscriber::registry().with(env_filter).with(file_layer).init(); @@ -40,33 +32,8 @@ pub fn init_tracing(config: &crate::config::Config) -> Option { - let stdout_layer = fmt::layer() - .with_target(true) - .with_file(log_annotations) - .with_line_number(log_annotations) - .json(); - - tracing_subscriber::registry() - .with(env_filter) - .with(stdout_layer) - .init(); - - tracing::info!( - log_output = %log_output, - log_format = "json", - rust_log = %env_filter_str, - "Tracing initialized" - ); - - None - } _ => { - let stdout_layer = fmt::layer() - .with_target(true) - .with_file(log_annotations) - .with_line_number(log_annotations) - .pretty(); + let stdout_layer = fmt::layer().with_target(true).pretty(); tracing_subscriber::registry() .with(env_filter) @@ -86,10 +53,9 @@ pub fn init_tracing(config: &crate::config::Config) -> Option, _grpc_shutdown: tokio::sync::oneshot::Sender<()>, + indexer: Option>, _storage_dir: tempfile::TempDir, } +impl Drop for GrpcTestContext { + fn drop(&mut self) { + if let Some(indexer) = self.indexer.take() { + indexer.abort(); + } + } +} + impl GrpcTestContext { pub async fn start() -> Self { let storage_dir = tempfile::tempdir().expect("Failed to create temp storage dir"); @@ -43,7 +52,9 @@ impl GrpcTestContext { let config = Config { database_url: "sqlite::memory:".to_string(), - hmac_secret: "test-hmac-secret-integration".to_string(), + token_index_key: "test-token-index-key".to_string(), + session_auth_key: "test-session-auth-key".to_string(), + signed_upload_key: "test-signed-upload-key".to_string(), storage_fs_path: Some(storage_path.clone()), cookie_secure: false, mcp_enabled: false, @@ -58,6 +69,7 @@ impl GrpcTestContext { bcrypt_cost: bcrypt::DEFAULT_COST, webhook_allow_private_targets: true, backup_local_path: Some(format!("{storage_path}/backups")), + search_index_path: Some(format!("{storage_path}/search")), ..Default::default() }; @@ -69,7 +81,7 @@ impl GrpcTestContext { seed_admin(&repository).await; - let mut storage_registry = StorageRegistry::new(); + let storage_registry = StorageRegistry::new(); let fs_storage = cms::storage::FileSystemStorage::new(&storage_path).expect("Failed to init filesystem storage"); storage_registry.register(STORAGE_KIND_FILESYSTEM, Arc::new(fs_storage)); @@ -79,6 +91,20 @@ impl GrpcTestContext { let repository_arc = Arc::new(repository.clone()); let services = Services::new(repository_arc.clone(), &pool, &config); + // Mirror server startup: gRPC writes enqueue search updates, so tests need + // the single index consumer running against an isolated per-test index. + let indexer = if let (Some(search), Some(queue)) = (services.search.clone(), services.search_queue.clone()) { + let repo = repository_arc.clone(); + Some(tokio::spawn(async move { + if search.is_empty() { + let _ = search.rebuild_all(&repo).await; + } + cms::services::search::indexer::run(search, queue, repo).await; + })) + } else { + None + }; + // Start Axum server for REST-based seeding let axum_listener = TcpListener::bind("127.0.0.1:0") .await @@ -94,6 +120,9 @@ impl GrpcTestContext { backup_destination, &config, )); + let settings = cms::services::settings::SettingsService::load(pool.clone(), &"11".repeat(32)) + .await + .expect("Failed to init instance settings"); let app = create_router( pool.clone(), @@ -102,6 +131,7 @@ impl GrpcTestContext { storage_registry.clone(), services.clone(), backup_service, + settings, ); let (axum_shutdown_tx, axum_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); @@ -180,6 +210,7 @@ impl GrpcTestContext { rest_base_url, _shutdown: axum_shutdown_tx, _grpc_shutdown: grpc_shutdown_tx, + indexer, _storage_dir: storage_dir, } } diff --git a/apps/backend/tests/common/server.rs b/apps/backend/tests/common/server.rs index 909eb6ba..94134bf9 100644 --- a/apps/backend/tests/common/server.rs +++ b/apps/backend/tests/common/server.rs @@ -51,7 +51,9 @@ impl TestServer { let config = Config { database_url, - hmac_secret: "test-hmac-secret-integration".to_string(), + token_index_key: "test-token-index-key".to_string(), + session_auth_key: "test-session-auth-key".to_string(), + signed_upload_key: "test-signed-upload-key".to_string(), storage_fs_path: Some(storage_path.clone()), cookie_secure: false, // Real config defaults this to 24h; `Config::default()` leaves it 0, which @@ -96,7 +98,7 @@ impl TestServer { seed_admin(&repository).await; - let mut storage_registry = StorageRegistry::new(); + let storage_registry = StorageRegistry::new(); let fs_storage = cms::storage::FileSystemStorage::new(&storage_path).expect("Failed to init filesystem storage"); storage_registry.register(STORAGE_KIND_FILESYSTEM, Arc::new(fs_storage)); @@ -124,6 +126,9 @@ impl TestServer { backup_destination, &config, )); + let settings = cms::services::settings::SettingsService::load(pool.clone(), &"11".repeat(32)) + .await + .expect("Failed to init instance settings"); let app = create_router( pool.clone(), @@ -132,6 +137,7 @@ impl TestServer { storage_registry, services, backup_service, + settings, ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); diff --git a/apps/backend/tests/grpc/entries_tests.rs b/apps/backend/tests/grpc/entries_tests.rs index 68753e97..7442dd96 100644 --- a/apps/backend/tests/grpc/entries_tests.rs +++ b/apps/backend/tests/grpc/entries_tests.rs @@ -2,8 +2,8 @@ use cms::grpc::cms::v1::collection_service_client::CollectionServiceClient; use cms::grpc::cms::v1::entry_service_client::EntryServiceClient; use cms::grpc::cms::v1::{ CreateCollectionRequest, CreateEntryRequest, DeleteEntryRequest, GetEntryRequest, GetEntryRevisionRequest, - ListEntriesRequest, ListEntryRevisionsRequest, PublishEntryRequest, RestoreEntryRevisionRequest, - UnpublishEntryRequest, UpdateEntryRequest, + ListEntriesRequest, ListEntriesResponse, ListEntryRevisionsRequest, PublishEntryRequest, + RestoreEntryRevisionRequest, UnpublishEntryRequest, UpdateEntryRequest, }; use crate::common::{GrpcTestContext, grpc::auth_interceptor}; @@ -29,6 +29,36 @@ async fn setup() -> (GrpcTestContext, String, String, String) { (ctx, site_id, token, collection.id) } +async fn wait_for_search( + client: &mut EntryServiceClient>, + collection_id: &str, + search: &str, + expected_slug: &str, +) -> ListEntriesResponse +where + I: tonic::service::Interceptor + Clone, +{ + for _ in 0..50 { + let response = client + .list_entries(tonic::Request::new(ListEntriesRequest { + collection_id: Some(collection_id.to_owned()), + status: None, + search: Some(search.to_owned()), + page: 1, + per_page: 10, + })) + .await + .unwrap() + .into_inner(); + if response.items.iter().any(|item| item.slug == expected_slug) { + return response; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + + panic!("{expected_slug} never became searchable"); +} + #[tokio::test] async fn test_create_entry() { let (ctx, _site_id, token, collection_id) = setup().await; @@ -131,17 +161,9 @@ async fn test_list_entries_with_search() { .await .unwrap(); - let resp = client - .list_entries(tonic::Request::new(ListEntriesRequest { - collection_id: Some(collection_id), - status: None, - search: Some("Unique".into()), - page: 1, - per_page: 10, - })) - .await - .unwrap() - .into_inner(); + // Search indexing is asynchronous in production. Poll briefly until the + // queue consumer publishes the committed document to the reader. + let resp = wait_for_search(&mut client, &collection_id, "Unique", "searchable").await; let slugs: Vec<&str> = resp.items.iter().map(|i| i.slug.as_str()).collect(); assert!( @@ -151,6 +173,49 @@ async fn test_list_entries_with_search() { ); } +#[tokio::test] +async fn test_search_indexes_are_isolated_across_concurrent_contexts() { + let ((ctx_a, _site_a, token_a, collection_a), (ctx_b, _site_b, token_b, collection_b)) = + tokio::join!(setup(), setup()); + + let channel_a = ctx_a.connect().await; + let channel_b = ctx_b.connect().await; + let mut client_a = EntryServiceClient::with_interceptor(channel_a, auth_interceptor(&token_a)); + let mut client_b = EntryServiceClient::with_interceptor(channel_b, auth_interceptor(&token_b)); + + for (client, collection_id, slug) in [ + (&mut client_a, collection_a.clone(), "context-a"), + (&mut client_b, collection_b.clone(), "context-b"), + ] { + let created = client + .create_entry(tonic::Request::new(CreateEntryRequest { + collection_id, + data: r#"{"title":"Running"}"#.into(), + slug: slug.into(), + })) + .await + .unwrap() + .into_inner(); + client + .publish_entry(tonic::Request::new(PublishEntryRequest { id: created.id })) + .await + .unwrap(); + } + + for (client, collection_id, expected_slug, other_slug) in [ + (&mut client_a, collection_a, "context-a", "context-b"), + (&mut client_b, collection_b, "context-b", "context-a"), + ] { + // Exercise Tantivy's stemming rather than an exact stored-value match. + let response = wait_for_search(client, &collection_id, "run", expected_slug).await; + let slugs: Vec<&str> = response.items.iter().map(|item| item.slug.as_str()).collect(); + assert!( + !slugs.contains(&other_slug), + "search index leaked across contexts: {slugs:?}" + ); + } +} + #[tokio::test] async fn test_update_entry() { let (ctx, _site_id, token, collection_id) = setup().await; diff --git a/apps/backend/tests/mcp/stdio_proxy_tests.rs b/apps/backend/tests/mcp/stdio_proxy_tests.rs index ff58ddaf..d9e4e958 100644 --- a/apps/backend/tests/mcp/stdio_proxy_tests.rs +++ b/apps/backend/tests/mcp/stdio_proxy_tests.rs @@ -20,15 +20,15 @@ struct StdioClient { impl StdioClient { /// Spawn the proxy the way an MCP client does: it knows only the server URL and a - /// site token — no `DATABASE_URL`, `HMAC_SECRET`, or `VCMS_HOME`. + /// site token and no server configuration. async fn start(url: &str, token: &str) -> Self { let mut child = Command::new(env!("CARGO_BIN_EXE_vcms")) .args(["mcp", "stdio"]) + .env("VCMS_MCP_URL", url) + .env("VCMS_MCP_TOKEN", token) .env_remove("DATABASE_URL") .env_remove("HMAC_SECRET") .env_remove("VCMS_HOME") - .env("VCMS_MCP_URL", url) - .env("VCMS_MCP_TOKEN", token) .env("RUST_LOG", "warn") .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/apps/backend/tests/rest/backups_tests.rs b/apps/backend/tests/rest/backups_tests.rs index 8cfe16b3..fc30247d 100644 --- a/apps/backend/tests/rest/backups_tests.rs +++ b/apps/backend/tests/rest/backups_tests.rs @@ -146,7 +146,8 @@ async fn site_backup_and_restore_round_trip() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204, "restore site"); + assert_eq!(resp.status(), 200, "restore site"); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); // The entry is back. assert_eq!(get_entry_status(&server, &token, &csrf, &site_id, &entry_id).await, 200); @@ -215,7 +216,8 @@ async fn encrypted_backup_round_trips() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204); + assert_eq!(resp.status(), 200); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); assert_eq!(get_entry_status(&server, &token, &csrf, &site_id, &entry_id).await, 200); } @@ -439,7 +441,8 @@ async fn inspect_lists_sites_and_multi_site_restore_round_trips() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204, "multi-site restore"); + assert_eq!(resp.status(), 200, "multi-site restore"); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); assert_eq!(get_entry_status(&server, &token, &csrf, &site_a, &entry_a).await, 200); assert_eq!(get_entry_status(&server, &token, &csrf, &site_b, &entry_b).await, 200); @@ -518,7 +521,8 @@ async fn instance_backup_and_restore_round_trip() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204, "restore instance"); + assert_eq!(resp.status(), 200, "restore instance"); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); // Instance restore wipes sessions; re-login, then verify the data is back. let (token2, csrf2) = login(&server, "admin", "admin").await; diff --git a/apps/backend/tests/rest/files_tests.rs b/apps/backend/tests/rest/files_tests.rs index b921a904..1c5840d5 100644 --- a/apps/backend/tests/rest/files_tests.rs +++ b/apps/backend/tests/rest/files_tests.rs @@ -348,7 +348,7 @@ async fn test_upload_file_invalid_mime_type() { // Tokens are minted directly with the lib (same HMAC secret as TestServer), so // these tests cover the PUT endpoint without an MCP round-trip. -const TEST_HMAC_SECRET: &str = "test-hmac-secret-integration"; +const TEST_HMAC_SECRET: &str = "test-signed-upload-key"; fn mint_upload_url(server: &TestServer, site_id: &str, filename: &str, mime: &str, expiry_secs: i64) -> String { let (_, encoded) = cms::signed_upload::SignedUploadToken::generate_with_storage_provider( @@ -493,6 +493,41 @@ async fn test_signed_upload_oversize_rejected() { } } +#[tokio::test] +async fn test_signed_chunked_upload_uses_live_instance_limit() { + let server = TestServer::start().await; + let (token, csrf, site_id) = setup(&server).await; + let client = reqwest::Client::new(); + let update = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, + "public_registration": false, + "session_lifetime_hours": 24, + "upload_limit_mb": 1, + "mcp_enabled": true + })) + .send() + .await + .unwrap(); + assert_eq!(update.status(), 200); + + let url = mint_upload_url(&server, &site_id, "chunked.txt", "text/plain", 900); + let chunks = futures_util::stream::iter([ + Ok::<_, std::io::Error>(bytes::Bytes::from(vec![b'a'; 700_000])), + Ok(bytes::Bytes::from(vec![b'b'; 700_000])), + ]); + let response = client + .put(url) + .header("Content-Type", "text/plain") + .body(reqwest::Body::wrap_stream(chunks)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 413); +} + /// Multipart path enforces the same sniffing as the signed path. #[tokio::test] async fn test_multipart_upload_magic_byte_mismatch() { diff --git a/apps/backend/tests/rest/main.rs b/apps/backend/tests/rest/main.rs index 719c70b6..8dfc9151 100644 --- a/apps/backend/tests/rest/main.rs +++ b/apps/backend/tests/rest/main.rs @@ -9,6 +9,7 @@ mod entries_tests; mod files_tests; mod health_tests; mod roles_tests; +mod settings_tests; mod singletons_tests; mod sites_tests; mod webhooks_tests; diff --git a/apps/backend/tests/rest/settings_tests.rs b/apps/backend/tests/rest/settings_tests.rs new file mode 100644 index 00000000..cbd898b3 --- /dev/null +++ b/apps/backend/tests/rest/settings_tests.rs @@ -0,0 +1,116 @@ +use serde_json::{Value, json}; + +use crate::common::{ + TestServer, + auth::{auth_header, extract_cookies}, +}; + +async fn owner_session(server: &TestServer, client: &reqwest::Client) -> (String, String) { + let response = server.login_user(client, "admin@cms.local", "admin").await; + assert_eq!(response.status(), 200); + extract_cookies(&response) +} + +#[tokio::test] +async fn owner_can_read_redacted_settings_and_admin_cannot() { + let server = TestServer::start().await; + let client = reqwest::Client::new(); + let (token, csrf) = owner_session(&server, &client).await; + let response = client + .get(format!("{}/api/dashboard/instance/settings", server.base_url)) + .headers(auth_header(&token, &csrf)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["general"]["upload_limit_mb"], 50); + assert!(body["storage_credentials"]["configured"].is_boolean()); + let serialized = body.to_string(); + assert!(!serialized.contains("secret_access_key")); + assert!(!serialized.contains("master_key")); + + let create = client + .post(format!("{}/api/dashboard/instance/users", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "name": "settings admin", "email": "settings-admin@example.com", + "temporary_password": "password123", "instance_role": "instance_admin" + })) + .send() + .await + .unwrap(); + assert!(create.status().is_success()); + let login = server + .login_user(&client, "settings-admin@example.com", "password123") + .await; + let (admin_token, admin_csrf) = extract_cookies(&login); + let forbidden = client + .get(format!("{}/api/dashboard/instance/settings", server.base_url)) + .headers(auth_header(&admin_token, &admin_csrf)) + .send() + .await + .unwrap(); + assert_eq!(forbidden.status(), 403); +} + +#[tokio::test] +async fn general_update_applies_registration_immediately() { + let server = TestServer::start().await; + let client = reqwest::Client::new(); + let (token, csrf) = owner_session(&server, &client).await; + let response = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, + "public_registration": true, + "session_lifetime_hours": 24, + "upload_limit_mb": 50, + "mcp_enabled": true + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + + let register = client + .post(format!("{}/api/auth/register", server.base_url)) + .json(&json!({"name": "New User", "email": "new@example.com", "password": "password123"})) + .send() + .await + .unwrap(); + assert_eq!(register.status(), 201); +} + +#[tokio::test] +async fn settings_updates_reject_unknown_fields_and_invalid_limits() { + let server = TestServer::start().await; + let client = reqwest::Client::new(); + let (token, csrf) = owner_session(&server, &client).await; + let response = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, "public_registration": false, + "session_lifetime_hours": 24, "upload_limit_mb": 0, + "mcp_enabled": true, "mystery": true + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 422); + + let invalid = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, "public_registration": false, + "session_lifetime_hours": 24, "upload_limit_mb": 0, + "mcp_enabled": true + })) + .send() + .await + .unwrap(); + assert_eq!(invalid.status(), 400); +} diff --git a/apps/dashboard/src/components/instance/settings-forms.tsx b/apps/dashboard/src/components/instance/settings-forms.tsx new file mode 100644 index 00000000..db762eae --- /dev/null +++ b/apps/dashboard/src/components/instance/settings-forms.tsx @@ -0,0 +1,637 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupInput } from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + getInstanceSettings, + type InstanceSettings, + updateInstanceSettingsSection, +} from "@/lib/api"; + +export function SettingsLoading() { + return ( + + + + + + + + + + + + ); +} + +function useSettings() { + return useQuery({ + queryKey: ["instance-settings"], + queryFn: getInstanceSettings, + }); +} + +function useSave(section: "general" | "security" | "storage" | "backups") { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: unknown) => updateInstanceSettingsSection(section, data), + onSuccess: (data) => { + queryClient.setQueryData(["instance-settings"], data); + toast.success("Settings saved"); + }, + onError: (error: Error) => toast.error(error.message), + }); +} + +function ToggleField({ + label, + description, + checked, + onCheckedChange, +}: { + label: string; + description: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + +
+ {label} + {description} +
+ +
+ ); +} + +function TextField({ + label, + value, + onChange, + placeholder, + type = "text", +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: string; +}) { + return ( + + {label} + onChange(event.target.value)} + /> + + ); +} + +export function GeneralSettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function GeneralForm({ settings }: { settings: InstanceSettings }) { + const [form, setForm] = useState(settings.general); + const save = useSave("general"); + return ( + + + General + + Public behavior and user-facing runtime limits. + + + + + + setForm({ ...form, public_url: value || null }) + } + /> +
+ + setForm({ ...form, session_lifetime_hours: Number(value) }) + } + /> + + setForm({ ...form, upload_limit_mb: Number(value) }) + } + /> +
+ + setForm({ ...form, public_registration: value }) + } + /> + + setForm({ ...form, mcp_enabled: value }) + } + /> +
+
+ + + +
+ ); +} + +export function SecuritySettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function lines(value: string) { + return value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function SecurityForm({ settings }: { settings: InstanceSettings }) { + const initial = settings.security; + const [form, setForm] = useState(initial); + const save = useSave("security"); + return ( + + + Security + + Browser, proxy, webhook, and MCP trust boundaries. + + + + + + setForm({ ...form, allowed_origins: lines(value) }) + } + /> + + setForm({ ...form, mcp_allowed_hosts: lines(value) }) + } + /> + + setForm({ ...form, mcp_allowed_origins: lines(value) }) + } + /> + + setForm({ ...form, secure_cookies: value }) + } + /> + + setForm({ ...form, trusted_proxy_headers: value }) + } + /> + + setForm({ ...form, private_webhook_targets: value }) + } + /> + + + + + + + ); +} + +type ProviderSection = + | InstanceSettings["storage"] + | InstanceSettings["backups"]; + +function Credentials({ + configured, + masked, + accessKey, + secretKey, + setAccessKey, + setSecretKey, +}: { + configured: boolean; + masked: string | null; + accessKey: string; + secretKey: string; + setAccessKey: (value: string) => void; + setSecretKey: (value: string) => void; +}) { + return ( +
+ +
+ Access key ID + {configured && Configured {masked}} +
+ + setAccessKey(event.target.value)} + /> + +
+ + Secret access key + + setSecretKey(event.target.value)} + /> + + +
+ ); +} + +function ProviderFields({ + section, + onChange, +}: { + section: ProviderSection; + onChange: (next: ProviderSection) => void; +}) { + const provider = + "provider" in section ? section.provider : section.destination; + const setProvider = (value: "filesystem" | "s3") => + onChange( + "provider" in section + ? { ...section, provider: value } + : { ...section, destination: value }, + ); + return ( + <> + + Provider + + + {provider === "s3" && ( +
+ + onChange({ ...section, bucket: value || null }) + } + /> + + onChange({ ...section, region: value || null }) + } + /> + + onChange({ ...section, endpoint: value || null }) + } + /> + {"public_url" in section && ( + + onChange({ ...section, public_url: value || null }) + } + /> + )} +
+ )} + + ); +} + +export function StorageSettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function StorageForm({ settings }: { settings: InstanceSettings }) { + const [form, setForm] = useState(settings.storage); + const [accessKey, setAccessKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [confirming, setConfirming] = useState(false); + const save = useSave("storage"); + const targetChanged = + (settings.storage.provider === "s3" || form.provider === "s3") && + (settings.storage.provider !== form.provider || + settings.storage.bucket !== form.bucket || + settings.storage.region !== form.region || + settings.storage.endpoint !== form.endpoint); + const submit = (confirmed = false) => + save.mutate( + { + ...form, + access_key_id: accessKey || null, + secret_access_key: secretKey || null, + confirm_target_change: confirmed, + }, + { + onSuccess: () => { + setAccessKey(""); + setSecretKey(""); + }, + }, + ); + return ( + <> + + + Storage + + One global provider. Filesystem paths stay fixed under the data + root. + + + + + setForm(next as InstanceSettings["storage"])} + /> + {form.provider === "s3" && ( + + )} + + + + + + + submit(true)} + /> + + ); +} + +function ConfirmTarget({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + + + Change S3 target? + + Existing objects are not moved. Confirm only after arranging + migration or accepting that old files may be unavailable. + + + + Cancel + + Change target + + + + + ); +} + +export function BackupSettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function BackupForm({ settings }: { settings: InstanceSettings }) { + const [form, setForm] = useState(settings.backups); + const [accessKey, setAccessKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [confirming, setConfirming] = useState(false); + const save = useSave("backups"); + const targetChanged = + (settings.backups.destination === "s3" || form.destination === "s3") && + (settings.backups.destination !== form.destination || + settings.backups.bucket !== form.bucket || + settings.backups.region !== form.region || + settings.backups.endpoint !== form.endpoint); + const submit = (confirmed = false) => + save.mutate( + { + ...form, + access_key_id: accessKey || null, + secret_access_key: secretKey || null, + confirm_target_change: confirmed, + }, + { + onSuccess: () => { + setAccessKey(""); + setSecretKey(""); + }, + }, + ); + return ( + <> + + + Backup destination + + Scheduled backup policy and its separate destination. + + + + + setForm({ ...form, enabled: value })} + /> + + setForm({ ...form, retention: Number(value) }) + } + /> + setForm(next as InstanceSettings["backups"])} + /> + {form.destination === "s3" && ( + + )} + + + + + + + submit(true)} + /> + + ); +} diff --git a/apps/dashboard/src/components/ui/switch.tsx b/apps/dashboard/src/components/ui/switch.tsx new file mode 100644 index 00000000..add5028f --- /dev/null +++ b/apps/dashboard/src/components/ui/switch.tsx @@ -0,0 +1,30 @@ +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 9c589e7f..363c3a2f 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -106,6 +106,61 @@ export function isOperator(role: InstanceRole | null | undefined): boolean { return role === "instance_owner" || role === "instance_admin"; } +export interface InstanceSettings { + version: number; + general: { + public_url: string | null; + public_registration: boolean; + session_lifetime_hours: number; + upload_limit_mb: number; + mcp_enabled: boolean; + }; + security: { + secure_cookies: boolean; + allowed_origins: string[]; + trusted_proxy_headers: boolean; + private_webhook_targets: boolean; + mcp_allowed_hosts: string[]; + mcp_allowed_origins: string[]; + }; + storage: { + provider: "filesystem" | "s3"; + bucket: string | null; + region: string | null; + endpoint: string | null; + public_url: string | null; + }; + backups: { + enabled: boolean; + destination: "filesystem" | "s3"; + retention: number; + bucket: string | null; + region: string | null; + endpoint: string | null; + }; + storage_credentials: CredentialState; + backup_credentials: CredentialState; +} + +export interface CredentialState { + configured: boolean; + masked_access_key_id: string | null; +} + +export function getInstanceSettings() { + return api("/instance/settings"); +} + +export function updateInstanceSettingsSection( + section: "general" | "security" | "storage" | "backups", + data: unknown, +) { + return api(`/instance/settings/${section}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + /** Human label for an instance role, used in settings/user lists. */ export function instanceRoleLabel( role: InstanceRole | null | undefined, @@ -700,8 +755,10 @@ export function backupDownloadUrl( return `${BASE_URL}${backupScopePrefix(scope)}/backups/${backupId}/download`; } +export type RestoreReport = { recovery_required: string[] }; + export async function restoreBackup(scope: BackupScope, input: RestoreInput) { - return api(`${backupScopePrefix(scope)}/restore`, { + return api(`${backupScopePrefix(scope)}/restore`, { method: "POST", body: JSON.stringify(input), }); @@ -741,6 +798,7 @@ export async function restoreBackupUpload( body, ); } + return (await res.json()) as RestoreReport; } /** Inspect a stored backup (by id or key) to list the sites it contains. */ diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index d6c7ab78..3f0beafc 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -24,6 +24,8 @@ import { Route as AdminSitesSiteIdSettingsRouteImport } from './routes/_admin/si import { Route as AdminSitesSiteIdFilesRouteImport } from './routes/_admin/sites.$siteId/files' import { Route as AdminSitesSiteIdCollectionsRouteImport } from './routes/_admin/sites.$siteId/collections' import { Route as AdminShellSettingsUsersRouteImport } from './routes/_admin/_shell/settings/users' +import { Route as AdminShellSettingsStorageRouteImport } from './routes/_admin/_shell/settings/storage' +import { Route as AdminShellSettingsSecurityRouteImport } from './routes/_admin/_shell/settings/security' import { Route as AdminShellSettingsBackupsRouteImport } from './routes/_admin/_shell/settings/backups' import { Route as AdminSitesSiteIdSettingsIndexRouteImport } from './routes/_admin/sites.$siteId/settings/index' import { Route as AdminSitesSiteIdSingletonsSlugRouteImport } from './routes/_admin/sites.$siteId/singletons.$slug' @@ -111,6 +113,18 @@ const AdminShellSettingsUsersRoute = AdminShellSettingsUsersRouteImport.update({ path: '/users', getParentRoute: () => AdminShellSettingsRoute, } as any) +const AdminShellSettingsStorageRoute = + AdminShellSettingsStorageRouteImport.update({ + id: '/storage', + path: '/storage', + getParentRoute: () => AdminShellSettingsRoute, + } as any) +const AdminShellSettingsSecurityRoute = + AdminShellSettingsSecurityRouteImport.update({ + id: '/security', + path: '/security', + getParentRoute: () => AdminShellSettingsRoute, + } as any) const AdminShellSettingsBackupsRoute = AdminShellSettingsBackupsRouteImport.update({ id: '/backups', @@ -187,6 +201,8 @@ export interface FileRoutesByFullPath { '/settings': typeof AdminShellSettingsRouteWithChildren '/sites/$siteId': typeof AdminSitesSiteIdRouteWithChildren '/settings/backups': typeof AdminShellSettingsBackupsRoute + '/settings/security': typeof AdminShellSettingsSecurityRoute + '/settings/storage': typeof AdminShellSettingsStorageRoute '/settings/users': typeof AdminShellSettingsUsersRoute '/sites/$siteId/collections': typeof AdminSitesSiteIdCollectionsRoute '/sites/$siteId/files': typeof AdminSitesSiteIdFilesRoute @@ -211,6 +227,8 @@ export interface FileRoutesByTo { '/sites': typeof AdminSitesRouteWithChildren '/account': typeof AdminShellAccountRoute '/settings/backups': typeof AdminShellSettingsBackupsRoute + '/settings/security': typeof AdminShellSettingsSecurityRoute + '/settings/storage': typeof AdminShellSettingsStorageRoute '/settings/users': typeof AdminShellSettingsUsersRoute '/sites/$siteId/collections': typeof AdminSitesSiteIdCollectionsRoute '/sites/$siteId/files': typeof AdminSitesSiteIdFilesRoute @@ -238,6 +256,8 @@ export interface FileRoutesById { '/_admin/sites/$siteId': typeof AdminSitesSiteIdRouteWithChildren '/_admin/_shell/': typeof AdminShellIndexRoute '/_admin/_shell/settings/backups': typeof AdminShellSettingsBackupsRoute + '/_admin/_shell/settings/security': typeof AdminShellSettingsSecurityRoute + '/_admin/_shell/settings/storage': typeof AdminShellSettingsStorageRoute '/_admin/_shell/settings/users': typeof AdminShellSettingsUsersRoute '/_admin/sites/$siteId/collections': typeof AdminSitesSiteIdCollectionsRoute '/_admin/sites/$siteId/files': typeof AdminSitesSiteIdFilesRoute @@ -266,6 +286,8 @@ export interface FileRouteTypes { | '/settings' | '/sites/$siteId' | '/settings/backups' + | '/settings/security' + | '/settings/storage' | '/settings/users' | '/sites/$siteId/collections' | '/sites/$siteId/files' @@ -290,6 +312,8 @@ export interface FileRouteTypes { | '/sites' | '/account' | '/settings/backups' + | '/settings/security' + | '/settings/storage' | '/settings/users' | '/sites/$siteId/collections' | '/sites/$siteId/files' @@ -316,6 +340,8 @@ export interface FileRouteTypes { | '/_admin/sites/$siteId' | '/_admin/_shell/' | '/_admin/_shell/settings/backups' + | '/_admin/_shell/settings/security' + | '/_admin/_shell/settings/storage' | '/_admin/_shell/settings/users' | '/_admin/sites/$siteId/collections' | '/_admin/sites/$siteId/files' @@ -447,6 +473,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminShellSettingsUsersRouteImport parentRoute: typeof AdminShellSettingsRoute } + '/_admin/_shell/settings/storage': { + id: '/_admin/_shell/settings/storage' + path: '/storage' + fullPath: '/settings/storage' + preLoaderRoute: typeof AdminShellSettingsStorageRouteImport + parentRoute: typeof AdminShellSettingsRoute + } + '/_admin/_shell/settings/security': { + id: '/_admin/_shell/settings/security' + path: '/security' + fullPath: '/settings/security' + preLoaderRoute: typeof AdminShellSettingsSecurityRouteImport + parentRoute: typeof AdminShellSettingsRoute + } '/_admin/_shell/settings/backups': { id: '/_admin/_shell/settings/backups' path: '/backups' @@ -529,12 +569,16 @@ declare module '@tanstack/react-router' { interface AdminShellSettingsRouteChildren { AdminShellSettingsBackupsRoute: typeof AdminShellSettingsBackupsRoute + AdminShellSettingsSecurityRoute: typeof AdminShellSettingsSecurityRoute + AdminShellSettingsStorageRoute: typeof AdminShellSettingsStorageRoute AdminShellSettingsUsersRoute: typeof AdminShellSettingsUsersRoute AdminShellSettingsIndexRoute: typeof AdminShellSettingsIndexRoute } const AdminShellSettingsRouteChildren: AdminShellSettingsRouteChildren = { AdminShellSettingsBackupsRoute: AdminShellSettingsBackupsRoute, + AdminShellSettingsSecurityRoute: AdminShellSettingsSecurityRoute, + AdminShellSettingsStorageRoute: AdminShellSettingsStorageRoute, AdminShellSettingsUsersRoute: AdminShellSettingsUsersRoute, AdminShellSettingsIndexRoute: AdminShellSettingsIndexRoute, } diff --git a/apps/dashboard/src/routes/_admin/_shell/settings.tsx b/apps/dashboard/src/routes/_admin/_shell/settings.tsx index dd269cc6..7cbd6f8d 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings.tsx @@ -28,9 +28,13 @@ function InstanceSettingsLayout() { const isOwner = me?.instance_role === "instance_owner"; const active = pathname.endsWith("/settings/users") ? "users" - : pathname.endsWith("/settings/backups") - ? "backups" - : "general"; + : pathname.endsWith("/settings/security") + ? "security" + : pathname.endsWith("/settings/storage") + ? "storage" + : pathname.endsWith("/settings/backups") + ? "backups" + : "general"; return (
@@ -41,15 +45,17 @@ function InstanceSettingsLayout() {

- - - } - > - General - + + + {isOwner && ( + } + > + General + + )} Users + {isOwner && ( + } + > + Security + + )} + {isOwner && ( + } + > + Storage + + )} {isOwner && ( ; + return ( +
+ + +
+ ); } diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx index 98c11461..07aad7f8 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx @@ -1,44 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { getMe, instanceRoleLabel } from "@/lib/api"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { GeneralSettingsPanel } from "@/components/instance/settings-forms"; +import { getMe } from "@/lib/api"; export const Route = createFileRoute("/_admin/_shell/settings/")({ - component: GeneralInstanceSettings, + beforeLoad: async ({ context }) => { + const me = await context.queryClient.ensureQueryData({ + queryKey: ["me"], + queryFn: getMe, + }); + if (me.instance_role !== "instance_owner") + throw redirect({ to: "/settings/users" }); + }, + component: GeneralSettingsPanel, }); - -function Row({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function GeneralInstanceSettings() { - const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe }); - - return ( - - - General - - Overview of this CMS installation. More instance-wide settings will - live here. - - - - - - - - - ); -} diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/security.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/security.tsx new file mode 100644 index 00000000..06831a36 --- /dev/null +++ b/apps/dashboard/src/routes/_admin/_shell/settings/security.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { SecuritySettingsPanel } from "@/components/instance/settings-forms"; +import { getMe } from "@/lib/api"; + +export const Route = createFileRoute("/_admin/_shell/settings/security")({ + beforeLoad: async ({ context }) => { + const me = await context.queryClient.ensureQueryData({ + queryKey: ["me"], + queryFn: getMe, + }); + if (me.instance_role !== "instance_owner") + throw redirect({ to: "/settings/users" }); + }, + component: SecuritySettingsPanel, +}); diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx new file mode 100644 index 00000000..d7a80de8 --- /dev/null +++ b/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { StorageSettingsPanel } from "@/components/instance/settings-forms"; +import { getMe } from "@/lib/api"; + +export const Route = createFileRoute("/_admin/_shell/settings/storage")({ + beforeLoad: async ({ context }) => { + const me = await context.queryClient.ensureQueryData({ + queryKey: ["me"], + queryFn: getMe, + }); + if (me.instance_role !== "instance_owner") + throw redirect({ to: "/settings/users" }); + }, + component: StorageSettingsPanel, +}); diff --git a/packaging/linux/vcms.service b/packaging/linux/vcms.service index 51ae9bd2..2c4390eb 100644 --- a/packaging/linux/vcms.service +++ b/packaging/linux/vcms.service @@ -8,8 +8,7 @@ Wants=network-online.target Type=simple User=vcms Group=vcms -Environment=VCMS_HOME=/var/lib/vcms -ExecStart=/usr/bin/vcms serve +ExecStart=/usr/bin/vcms service run Restart=on-failure RestartSec=5s TimeoutStartSec=90s diff --git a/packaging/macos/com.velopulent.vcms.plist b/packaging/macos/com.velopulent.vcms.plist index 5d87916d..15dc6f3b 100644 --- a/packaging/macos/com.velopulent.vcms.plist +++ b/packaging/macos/com.velopulent.vcms.plist @@ -4,9 +4,7 @@ Labelcom.velopulent.vcms ProgramArguments - /usr/local/bin/vcmsserve--config/Library/Application Support/vcms/config.toml - EnvironmentVariables - VCMS_HOME/Library/Application Support/vcms + /usr/local/bin/vcmsservicerun UserName_vcms GroupName_vcms RunAtLoad diff --git a/packaging/windows/vcms.wxs.template b/packaging/windows/vcms.wxs.template index e1e96f54..7ce08c0e 100644 --- a/packaging/windows/vcms.wxs.template +++ b/packaging/windows/vcms.wxs.template @@ -1,5 +1,5 @@ - + @@ -7,6 +7,9 @@ + + + @@ -18,7 +21,11 @@ - + + + + + diff --git a/xtask/README.md b/xtask/README.md index 11eb2f62..3de0d24b 100644 --- a/xtask/README.md +++ b/xtask/README.md @@ -104,11 +104,12 @@ Native package tools are also required: MSI automation uses WiX v7's direct `-acceptEula wix7` build switch. Running an MSI build therefore accepts the WiX v7 OSMF EULA for that invocation. -Install the matching UI extension once before local MSI builds: +Install the matching UI and Util extensions once before local MSI builds: ```console wix eula accept wix7 wix extension add --global WixToolset.UI.wixext/7.0.0 +wix extension add --global WixToolset.Util.wixext/7.0.0 ``` Real native packages must be built on their matching OS. `--target-os` selects and diff --git a/xtask/src/linux.rs b/xtask/src/linux.rs index d453766e..951a1b16 100644 --- a/xtask/src/linux.rs +++ b/xtask/src/linux.rs @@ -139,11 +139,12 @@ mod tests { use super::*; #[test] - fn service_is_hardened_and_uses_journal() { + fn service_is_hardened_and_uses_installed_entrypoint() { let unit = systemd_service(); assert!(unit.contains("ProtectSystem=strict")); assert!(unit.contains("CapabilityBoundingSet=")); - assert!(!unit.contains("LOG_OUTPUT=file")); + assert!(unit.contains("ExecStart=/usr/bin/vcms service run")); + assert!(!unit.contains("VCMS_HOME")); } #[test] @@ -161,7 +162,11 @@ mod tests { let spec = include_str!("../../packaging/rpm/vcms.spec.template"); assert!(spec.contains("%config(noreplace) /var/lib/vcms/config.toml.sample")); - assert!(!spec.lines().any(|line| line == "%config(noreplace) /var/lib/vcms/config.toml")); + assert!( + !spec + .lines() + .any(|line| line == "%config(noreplace) /var/lib/vcms/config.toml") + ); assert!(spec.contains("cp /var/lib/vcms/config.toml.sample /var/lib/vcms/config.toml")); } } diff --git a/xtask/src/macos.rs b/xtask/src/macos.rs index d1a3a916..06d0fb36 100644 --- a/xtask/src/macos.rs +++ b/xtask/src/macos.rs @@ -72,10 +72,11 @@ mod tests { use super::*; #[test] - fn plist_uses_dedicated_identity_and_paths() { + fn plist_uses_dedicated_identity_and_installed_entrypoint() { let plist = launchd_plist(); assert!(plist.contains("UserName_vcms")); - assert!(plist.contains("/Library/Application Support/vcms")); + assert!(plist.contains("servicerun")); + assert!(!plist.contains("VCMS_HOME")); assert!(plist.contains("StandardErrorPath")); } diff --git a/xtask/src/portable.rs b/xtask/src/portable.rs index c7afd315..14aad108 100644 --- a/xtask/src/portable.rs +++ b/xtask/src/portable.rs @@ -26,7 +26,7 @@ pub fn build(context: &Context) -> Result { write_file( &stage.join("README.txt"), format!( - "Velopulent CMS portable package\n\nRun `{APP_NAME}` directly. No service is registered. Runtime files use platform user directories unless VCMS_HOME is set.\nTarget OS: {}\n", + "Velopulent CMS portable package\n\nRun `{APP_NAME} serve`. No service is registered. Runtime files are created in ./vcms_data under your working directory.\nTarget OS: {}\n", context.target_os.as_str() ), )?; diff --git a/xtask/src/shared.rs b/xtask/src/shared.rs index 7e7ac000..46e0c9df 100644 --- a/xtask/src/shared.rs +++ b/xtask/src/shared.rs @@ -56,21 +56,12 @@ pub fn find_file(root: &Path, extension: &str) -> Result { } pub fn config_sample() -> &'static str { - r#"bind_address = "0.0.0.0:3000" -grpc_bind_address = "0.0.0.0:50051" -max_upload_size_mb = 50 -cookie_secure = false -session_lifetime_hours = 24 -db_max_connections = 10 -rate_limit_max_requests = 100 -mcp_enabled = true -mcp_allowed_hosts = ["localhost", "127.0.0.1"] + r#"[server] +http_address = "127.0.0.1:3000" +grpc_address = "127.0.0.1:50051" [log] -level = "cms=info,vcms=info,tower_http=info,axum=info" -output = "stdout" -format = "json" -annotations = false -dir = "logs" +level = "cms=info,vcms=info" +output = "file" "# } diff --git a/xtask/src/windows.rs b/xtask/src/windows.rs index d31822b2..715e7a32 100644 --- a/xtask/src/windows.rs +++ b/xtask/src/windows.rs @@ -24,7 +24,8 @@ pub fn build(context: &Context) -> Result { fs::write(&artifact, b"dry-run msi\n")?; } else { let package = root.join("vcms.msi"); - let ui_extension = wix_ui_extension()?; + let ui_extension = wix_extension("WixToolset.UI.wixext")?; + let util_extension = wix_extension("WixToolset.Util.wixext")?; run_cmd( Command::new("wix") .arg("build") @@ -33,6 +34,8 @@ pub fn build(context: &Context) -> Result { .args(["-pdbtype", "none"]) .arg("-ext") .arg(ui_extension) + .arg("-ext") + .arg(util_extension) .arg(root.join("vcms.wxs")) .arg("-out") .arg(&package) @@ -45,14 +48,17 @@ pub fn build(context: &Context) -> Result { Ok(artifact) } -fn wix_ui_extension() -> Result { +fn wix_extension(name: &str) -> Result { let profile = env::var_os("USERPROFILE").ok_or("USERPROFILE is not set")?; - let extension = - PathBuf::from(profile).join(".wix/extensions/WixToolset.UI.wixext/7.0.0/wixext7/WixToolset.UI.wixext.dll"); + let extension = PathBuf::from(profile) + .join(".wix/extensions") + .join(name) + .join("7.0.0/wixext7") + .join(format!("{name}.dll")); if extension.is_file() { Ok(extension) } else { - Err("WiX UI extension missing; run `wix extension add --global WixToolset.UI.wixext/7.0.0`".into()) + Err(format!("WiX extension missing; run `wix extension add --global {name}/7.0.0`").into()) } } @@ -132,10 +138,26 @@ mod tests { r#""# )); assert!(source.contains("ProgramDataVcms")); + assert!(source.contains(r#"xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util""#)); + assert!(source.contains(r#""#)); + assert!(source.contains( + r#""# + )); + assert!(!source.contains(""#)); assert!(source.contains(r#"ProductCode=""#)); assert!(!source.contains("@PRODUCT_CODE@")); assert!(source.contains(r#""#)); assert!(source.contains(r#"