diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..02087b4c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 +updates: + # Rust backend (workspace). Replaces hand-bumping deps. + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + cargo-minor-patch: + update-types: + - minor + - patch + + # Frontend + web (bun reads package.json; npm ecosystem covers the workspace). + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + npm-minor-patch: + update-types: + - minor + - patch + + # Keep the SHA-pinned GitHub Actions current so pinning doesn't rot. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + actions-all: + patterns: + - "*" diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 00000000..008bfa4d --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,31 @@ +name: Security audit + +# Scheduled RustSec advisory scan. Unlike the per-PR cargo-deny job, this catches +# NEW advisories filed against the existing Cargo.lock even when no code changes. +on: + schedule: + # 06:00 UTC every Monday. + - cron: "0 6 * * 1" + workflow_dispatch: + push: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + - ".github/workflows/audit.yml" + +permissions: + contents: read + issues: write + checks: write + +jobs: + audit: + name: cargo audit (RustSec) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index febdc657..2f171539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,37 +5,121 @@ on: push: branches: [main] -# Cancel superseded runs on the same ref to save CI minutes. +# Cancel superseded runs on the same ref to save CI minutes. Only cancel on PRs — +# never cancel an in-flight run on main, which could be the one catching a regression. concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Least privilege: jobs only read the repo (setup-protoc gets the token it needs). +permissions: + contents: read env: CARGO_TERM_COLOR: always jobs: + # Decide which areas changed so we only run the jobs that matter. Pure doc edits + # (README, CLAUDE.md, AGENTS.md, LICENSE, .agents/**, .claude/**) match no filter, + # so every build job below is skipped while the `ci-status` gate stays green. + changes: + name: Detect changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + rust: ${{ steps.filter.outputs.rust }} + dashboard: ${{ steps.filter.outputs.dashboard }} + web: ${{ steps.filter.outputs.web }} + other: ${{ steps.filter.outputs.other }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + rust: + - 'apps/backend/**' + - 'libs/proto/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'clippy.toml' + - 'rustfmt.toml' + - 'deny.toml' + - '.github/workflows/**' + dashboard: + - 'apps/dashboard/**' + - 'package.json' + - 'bun.lock' + - 'nx.json' + - '.github/workflows/**' + web: + - 'apps/web/**' + - 'package.json' + - 'bun.lock' + - 'nx.json' + - '.github/workflows/**' + # Catch-all: anything that is NOT purely docs/agent files. Guards against + # the default-skip trap where a change matching none of the area filters + # above (e.g. a new Dockerfile or top-level script) would silently skip all + # build jobs while ci-status stays green. The rust lanes treat `other` as a + # baseline trigger, so non-doc changes always compile + test the core. + other: + - '!**/*.md' + - '!.claude/**' + - '!.agents/**' + - '!LICENSE' + - '!AGENTS.md' + lint: name: Rust lint (fmt + clippy) + needs: changes + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.other == 'true' runs-on: ubuntu-latest + timeout-minutes: 20 defaults: run: working-directory: apps/backend steps: - - uses: actions/checkout@v4 - - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # Toolchain (channel + rustfmt/clippy components) comes from rust-toolchain.toml. + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + toolchain: "1.94" + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: apps/backend + # Share the dependency cache with the linux test lanes (deps are + # identical; clippy's final artifacts differ but the heavy 3rd-party + # layer is reused). rust-cache prunes to deps before saving, so a + # single shared cache serves lint + all 3 db lanes. + shared-key: linux - run: cargo fmt --all -- --check - - run: cargo clippy --all-targets --locked -- -D warnings + # --no-default-features drops `embed-dashboard`, whose RustEmbed derive needs + # apps/dashboard/dist at compile time (not built in CI). + - run: cargo clippy --no-default-features --all-targets --locked -- -D warnings + + security: + name: Supply-chain (cargo-deny) + needs: changes + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.other == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2 + with: + command: check test: name: Tests (${{ matrix.db }}) + needs: changes + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.other == 'true' runs-on: ubuntu-latest + timeout-minutes: 30 defaults: run: working-directory: apps/backend @@ -64,6 +148,8 @@ jobs: TEST_DATABASE_URL: ${{ matrix.test_database_url }} # Both services run for every lane; the sqlite lane simply ignores them # (GitHub Actions can't attach services conditionally per matrix value). + # Service containers are Linux-only, which is why Windows/macOS live in the + # separate `test-os` job (SQLite only). services: postgres: image: postgres:16 @@ -90,37 +176,125 @@ jobs: --health-timeout 5s --health-retries 15 steps: - - uses: actions/checkout@v4 - - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + with: + toolchain: "1.94" + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: apps/backend + # All 3 db lanes compile an identical binary (db is chosen at runtime + # via TEST_DATABASE), so one shared cache lets lanes 2 & 3 restore + # fully warm. Shared with the lint job too (deps are identical). + shared-key: linux + # --no-default-features drops `embed-dashboard` (RustEmbed needs a built + # apps/dashboard/dist at compile time, which CI doesn't produce). + - name: cargo test + run: cargo test --no-default-features --locked ${{ matrix.test_args }} + + # Cross-platform coverage. The binary ships on Windows/macOS/Linux, so the suite + # must run off Linux too. Service containers don't run on these runners, so only + # the SQLite lane (in-memory, no external server) is exercised here. + test-os: + name: Tests (${{ matrix.os }}, sqlite) + needs: changes + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.other == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + working-directory: apps/backend + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + env: + TEST_DATABASE: sqlite + TEST_DATABASE_URL: "" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + with: + toolchain: "1.94" + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: apps/backend - key: ${{ matrix.db }} + shared-key: ${{ matrix.os }} + # --no-default-features drops `embed-dashboard` (no built dashboard/dist in CI). - name: cargo test - run: cargo test --locked ${{ matrix.test_args }} + run: cargo test --no-default-features --locked frontend: name: Frontend (dashboard) + needs: changes + if: needs.changes.outputs.dashboard == 'true' runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install --frozen-lockfile - - name: Biome lint - run: bunx nx run dashboard:lint + # Persist the Nx local cache so dashboard:build replays across runs. + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .nx/cache + key: nx-${{ runner.os }}-${{ hashFiles('apps/dashboard/**', 'bun.lock', 'package.json', 'nx.json') }} + restore-keys: | + nx-${{ runner.os }}- + - name: Biome CI + run: bunx biome ci + working-directory: apps/dashboard - name: Typecheck - run: bun run test:dashboard + run: bunx nx run dashboard:typecheck - name: Build - run: bun run build:dashboard + run: bunx nx run dashboard:build web: name: Web (landing + docs) + needs: changes + if: needs.changes.outputs.web == 'true' runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install --frozen-lockfile + # Persist the Nx local cache so web:build replays across runs. + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .nx/cache + key: nx-${{ runner.os }}-${{ hashFiles('apps/web/**', 'bun.lock', 'package.json', 'nx.json') }} + restore-keys: | + nx-${{ runner.os }}- + - name: Biome CI + run: bunx biome ci + working-directory: apps/web + - name: Typecheck + run: bunx nx run web:typecheck - name: Build - run: bun run build:web + run: bunx nx run web:build + + # Single required status check. Branch protection should require ONLY this job. + # It passes when every needed job either succeeded or was skipped (path filter), + # and fails if any of them actually failed or was cancelled. This avoids the + # "skipped required check stays pending forever" trap of naive path filtering. + ci-status: + name: CI status + needs: [changes, lint, security, test, test-os, frontend, web] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify no job failed + run: | + if echo '${{ join(needs.*.result, ',') }}' | grep -Eq 'failure|cancelled'; then + echo "A required job failed or was cancelled: ${{ join(needs.*.result, ',') }}" + exit 1 + fi + echo "All jobs succeeded or were skipped: ${{ join(needs.*.result, ',') }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..68f65a9e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,124 @@ +name: Release + +# Tag-driven. Push a tag like `v1.2.3` to build per-OS/arch binaries and attach them +# to a GitHub Release. The release binary embeds apps/dashboard/dist via rust-embed, +# so the dashboard MUST be built before `cargo build --release`. +# +# The dashboard dist is static (JS/HTML) and identical for every target, so it is +# built ONCE here and shared to all build jobs as an artifact. The build matrix then +# runs natively on each OS/arch (no cross-compilation) and compiles cargo directly. +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + # Build the static dashboard once. Every build job consumes this same dist. + dashboard: + name: Build dashboard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + # Persist the Nx local cache across runs. Ephemeral runners wipe .nx/cache, so + # without this Nx caching is a no-op. This lets dashboard:build (8-10s) replay + # from cache instead of rebuilding. + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .nx/cache + key: nx-${{ runner.os }}-${{ hashFiles('apps/dashboard/**', 'bun.lock', 'package.json', 'nx.json') }} + restore-keys: | + nx-${{ runner.os }}- + - name: Build dashboard + run: | + bun install --frozen-lockfile + bunx nx run dashboard:build + - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: dashboard-dist + path: apps/dashboard/dist + if-no-files-found: error + + build: + name: Build (${{ matrix.os }}-${{ matrix.arch }}) + needs: dashboard + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-latest + bin: cms + asset: cms-${{ github.ref_name }}-linux-amd64 + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + bin: cms + asset: cms-${{ github.ref_name }}-linux-arm64 + - os: macos + arch: amd64 + runner: macos-26-intel + bin: cms + asset: cms-${{ github.ref_name }}-macos-amd64 + - os: macos + arch: arm64 + runner: macos-26 + bin: cms + asset: cms-${{ github.ref_name }}-macos-arm64 + - os: windows + arch: amd64 + runner: windows-latest + bin: cms.exe + asset: cms-${{ github.ref_name }}-windows-amd64.exe + - os: windows + arch: arm64 + runner: windows-11-arm + bin: cms.exe + asset: cms-${{ github.ref_name }}-windows-arm64.exe + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + # rust-embed (#[folder = "../dashboard/dist"]) needs the built dashboard present + # at compile time. Pull the shared artifact into apps/dashboard/dist. + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: dashboard-dist + path: apps/dashboard/dist + - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # Installs the host toolchain, which on each runner is already the target arch + # (native build, no --target needed). + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + with: + toolchain: "1.94" + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: apps/backend + key: release-${{ matrix.os }}-${{ matrix.arch }} + + # Build cargo directly (dist is already present from the artifact); keep the + # default `embed-dashboard` feature so rust-embed bakes in apps/dashboard/dist. + - name: Build release binary + working-directory: apps/backend + run: cargo build --release --locked + + - name: Stage artifact + shell: bash + run: | + mkdir -p dist + cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.asset }}" + + - name: Attach to release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + files: dist/${{ matrix.asset }} + generate_release_notes: true diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 49c8dff9..55c39b82 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -3,6 +3,9 @@ name = "cms" version = "0.1.0" edition = "2024" build = "build.rs" +# Application binary, never published to crates.io. Lets cargo-deny skip the +# license check for our own crate (see `[licenses] private` in deny.toml). +publish = false [features] default = ["embed-dashboard"] diff --git a/apps/backend/src/config.rs b/apps/backend/src/config.rs index ff1fce46..e3c01c8a 100644 --- a/apps/backend/src/config.rs +++ b/apps/backend/src/config.rs @@ -181,7 +181,7 @@ struct RawLog { impl Config { /// Load configuration by merging, in increasing precedence: /// built-in defaults < config file < environment variables < CLI flags. - pub fn load(cli: &Cli) -> Result { + pub fn load(cli: &Cli) -> Result> { let mut figment = Figment::new(); // Lowest-precedence secret layer: persisted HMAC secret from @@ -228,7 +228,7 @@ impl Config { figment = figment.merge(Serialized::default("log.level", v)); } - Ok(figment.extract::()?.into_config()) + Ok(figment.extract::().map_err(Box::new)?.into_config()) } /// Convenience for callers that only want env + defaults (no CLI flags). diff --git a/apps/backend/src/graphql/mutation/entry.rs b/apps/backend/src/graphql/mutation/entry.rs index ebae0a0f..15eed664 100644 --- a/apps/backend/src/graphql/mutation/entry.rs +++ b/apps/backend/src/graphql/mutation/entry.rs @@ -32,15 +32,15 @@ impl EntryMutation { let entry = gql_ctx .services .entry - .update_entry( - &id, + .update_entry(crate::services::entry::UpdateEntryInput { + id: &id, site_id, - input.data.as_ref().map(|d| &d.0), - input.slug.as_deref(), - input.status.as_deref(), + data: input.data.as_ref().map(|d| &d.0), + slug: input.slug.as_deref(), + status: input.status.as_deref(), created_by, - input.change_summary.as_deref(), - ) + change_summary: input.change_summary.as_deref(), + }) .await .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; diff --git a/apps/backend/src/graphql/query/mod.rs b/apps/backend/src/graphql/query/mod.rs index 01e2e48f..9ed0d989 100644 --- a/apps/backend/src/graphql/query/mod.rs +++ b/apps/backend/src/graphql/query/mod.rs @@ -67,6 +67,10 @@ impl QueryRoot { Ok(super::types::collection::db_collection_to_gql(db_collection)) } + // Each parameter is a GraphQL field argument exposed in the public schema and + // consumed positionally by the dashboard's queries; collapsing them into an + // input object would be a breaking schema change, so the arg count stands. + #[allow(clippy::too_many_arguments)] async fn entries( &self, ctx: &Context<'_>, diff --git a/apps/backend/src/grpc/auth.rs b/apps/backend/src/grpc/auth.rs index 7c169214..08e927f7 100644 --- a/apps/backend/src/grpc/auth.rs +++ b/apps/backend/src/grpc/auth.rs @@ -10,15 +10,19 @@ pub struct AuthContext { pub hmac: String, } +/// Returned when a raw Bearer token fails format validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidToken; + /// Parse and validate the raw Bearer token format. /// -/// Returns an `AuthContext` on success, or `()` on any validation failure. -pub fn parse_token(token: &str, config: &Config) -> Result { +/// Returns an `AuthContext` on success, or `InvalidToken` on any validation failure. +pub fn parse_token(token: &str, config: &Config) -> Result { if !token.starts_with("cms_site_") { - return Err(()); + return Err(InvalidToken); } - let prefix = token.get(..24).ok_or(())?.to_string(); + let prefix = token.get(..24).ok_or(InvalidToken)?.to_string(); let hmac = compute_key_hmac(token, &config.hmac_secret); Ok(AuthContext { diff --git a/apps/backend/src/grpc/server.rs b/apps/backend/src/grpc/server.rs index fda8bf37..91ab03ae 100644 --- a/apps/backend/src/grpc/server.rs +++ b/apps/backend/src/grpc/server.rs @@ -18,6 +18,9 @@ use crate::repository::Repository; use crate::services::Services; use crate::storage::StorageRegistry; +/// Boxed, pinned future returned by [`spawn_grpc_server`] (the gRPC server task). +type GrpcServerFuture = Pin>> + Send>>; + pub async fn start_grpc_server( services: Services, repository: Arc, @@ -102,7 +105,7 @@ pub fn spawn_grpc_server( config: Arc, storage_registry: Arc, grpc_addr: SocketAddr, -) -> Pin>> + Send>> { +) -> GrpcServerFuture { Box::pin(start_grpc_server( services, repository, diff --git a/apps/backend/src/grpc/services/entry.rs b/apps/backend/src/grpc/services/entry.rs index c6977522..7fe048fc 100644 --- a/apps/backend/src/grpc/services/entry.rs +++ b/apps/backend/src/grpc/services/entry.rs @@ -14,6 +14,7 @@ use crate::models::entry::{Entry, EntryRevision}; use crate::repository::Repository; use crate::repository::traits::ListEntriesParams; use crate::services::entry::EntryService as AppEntryService; +use crate::services::entry::UpdateEntryInput; #[derive(Clone)] pub struct EntryServiceImpl { @@ -112,15 +113,15 @@ impl EntryService for EntryServiceImpl { let entry = self .app_entry_service - .update_entry( - &req.id, - &site_id, - data.as_ref(), - req.slug.as_deref(), - req.status.as_deref(), - None, - req.change_summary.as_deref(), - ) + .update_entry(UpdateEntryInput { + id: &req.id, + site_id: &site_id, + data: data.as_ref(), + slug: req.slug.as_deref(), + status: req.status.as_deref(), + created_by: None, + change_summary: req.change_summary.as_deref(), + }) .await .map_err(|e| Status::internal(format!("Error: {}", e)))?; diff --git a/apps/backend/src/handlers/backup_handler.rs b/apps/backend/src/handlers/backup_handler.rs index 0fd6661f..90c8cb65 100644 --- a/apps/backend/src/handlers/backup_handler.rs +++ b/apps/backend/src/handlers/backup_handler.rs @@ -155,10 +155,10 @@ async fn load_scoped_backup( Ok(None) => return Err(err_response(BackupError::NotFound)), Err(e) => return Err(err_response(e)), }; - if let Some(site) = expect_site { - if row.scope != "site" || row.site_id.as_deref() != Some(site) { - return Err(err_response(BackupError::NotFound)); - } + if let Some(site) = expect_site + && (row.scope != "site" || row.site_id.as_deref() != Some(site)) + { + return Err(err_response(BackupError::NotFound)); } Ok(row) } @@ -309,10 +309,10 @@ async fn list_schedules(backup: &BackupService, scope: &str, site_id: Option<&st async fn update_schedule(backup: &BackupService, id: &str, expect_site: Option<&str>, body: ScheduleBody) -> Response { match meta::get_schedule(backup.pool(), id).await { Ok(Some(row)) => { - if let Some(site) = expect_site { - if row.scope != "site" || row.site_id.as_deref() != Some(site) { - return err_response(BackupError::NotFound); - } + if let Some(site) = expect_site + && (row.scope != "site" || row.site_id.as_deref() != Some(site)) + { + return err_response(BackupError::NotFound); } } Ok(None) => return err_response(BackupError::NotFound), @@ -344,10 +344,10 @@ async fn update_schedule(backup: &BackupService, id: &str, expect_site: Option<& async fn delete_schedule(backup: &BackupService, id: &str, expect_site: Option<&str>) -> Response { match meta::get_schedule(backup.pool(), id).await { Ok(Some(row)) => { - if let Some(site) = expect_site { - if row.scope != "site" || row.site_id.as_deref() != Some(site) { - return err_response(BackupError::NotFound); - } + if let Some(site) = expect_site + && (row.scope != "site" || row.site_id.as_deref() != Some(site)) + { + return err_response(BackupError::NotFound); } } Ok(None) => return err_response(BackupError::NotFound), @@ -370,10 +370,10 @@ async fn run_schedule_now( Ok(None) => return err_response(BackupError::NotFound), Err(e) => return err_response(e), }; - if let Some(site) = expect_site { - if row.scope != "site" || row.site_id.as_deref() != Some(site) { - return err_response(BackupError::NotFound); - } + if let Some(site) = expect_site + && (row.scope != "site" || row.site_id.as_deref() != Some(site)) + { + return err_response(BackupError::NotFound); } let scope = match row.scope.as_str() { "site" => match row.site_id.clone() { @@ -514,14 +514,14 @@ fn instance_restore_target( site_id: Option, site_ids: Option>, import_as_new: bool, -) -> Result { +) -> Result> { match mode { Some("site") => { let ids = match site_ids { Some(ids) if !ids.is_empty() => ids, _ => match site_id { Some(s) => vec![s], - None => return Err(bad_request("site restore requires site_ids or site_id")), + None => return Err(Box::new(bad_request("site restore requires site_ids or site_id"))), }, }; Ok(RestoreTarget::Sites { @@ -558,7 +558,7 @@ pub async fn restore_instance( body.import_as_new, ) { Ok(t) => t, - Err(r) => return r, + Err(r) => return *r, }; let resp = run_restore( &backup, @@ -570,10 +570,10 @@ pub async fn restore_instance( ) .await; // Clean up any single-use staged upload referenced by this restore. - if let Some(key) = body.destination_key.as_deref() { - if key.starts_with(TEMP_RESTORE_PREFIX) { - backup.delete_destination(key).await; - } + if let Some(key) = body.destination_key.as_deref() + && key.starts_with(TEMP_RESTORE_PREFIX) + { + backup.delete_destination(key).await; } resp } diff --git a/apps/backend/src/handlers/entry_handler.rs b/apps/backend/src/handlers/entry_handler.rs index e4c1d8ab..30c6ad13 100644 --- a/apps/backend/src/handlers/entry_handler.rs +++ b/apps/backend/src/handlers/entry_handler.rs @@ -20,12 +20,14 @@ pub struct RevisionId { number: i64, } +use crate::error::AppError; use crate::middleware::auth::{Actor, RequestContext, require_site_action}; use crate::models::authorization::Action; use crate::models::entry::{CreateEntry, Entry, EntryRevisionResponse, RevisionsListResponse, UpdateEntry}; use crate::repository::Repository; use crate::repository::traits::ListEntriesParams; use crate::services::Services; +use crate::services::entry::UpdateEntryInput; use crate::storage::{StorageProvider, StorageRegistry}; use crate::utils::diff::compute_diff_for_revision; @@ -52,14 +54,10 @@ pub struct DiffQuery { fn get_storage_for_site( site_storage_provider: &str, registry: &StorageRegistry, -) -> Result, Response> { - registry.get(site_storage_provider).ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Storage not configured"})), - ) - .into_response() - }) +) -> Result, AppError> { + registry + .get(site_storage_provider) + .ok_or(AppError::Internal("Storage provider not found".into())) } #[utoipa::path( @@ -156,7 +154,7 @@ pub async fn get_entry( .unwrap_or_else(|_| "filesystem".into()); let storage = match get_storage_for_site(&storage_provider, &storage_registry) { Ok(s) => s, - Err(resp) => return resp, + Err(e) => return e.into_response(), }; let resolved = services .entry @@ -239,15 +237,15 @@ pub async fn update_entry( let created_by = ctx.auth.actor.user_id(); match services .entry - .update_entry( - &id, - &ctx.site_id, - payload.data.as_ref(), - payload.slug.as_deref(), - payload.status.as_deref(), + .update_entry(UpdateEntryInput { + id: &id, + site_id: &ctx.site_id, + data: payload.data.as_ref(), + slug: payload.slug.as_deref(), + status: payload.status.as_deref(), created_by, - payload.change_summary.as_deref(), - ) + change_summary: payload.change_summary.as_deref(), + }) .await { Ok(item) => (StatusCode::OK, Json(item)).into_response(), diff --git a/apps/backend/src/handlers/file_handler.rs b/apps/backend/src/handlers/file_handler.rs index ecbb279a..70cb0a93 100644 --- a/apps/backend/src/handlers/file_handler.rs +++ b/apps/backend/src/handlers/file_handler.rs @@ -24,6 +24,7 @@ use crate::models::file::{BatchFileIds, FileWithUrl}; use crate::repository::Repository; use crate::repository::traits::ListFilesParams; use crate::services::Services; +use crate::services::file::UploadFileRequest; use crate::storage::{StorageProvider, StorageRegistry}; #[derive(Deserialize, utoipa::IntoParams)] @@ -204,15 +205,15 @@ pub async fn upload_file( match services .file - .upload_file( - &site_id, - file_data, - &file_name, - &content_type, + .upload_file(UploadFileRequest { + site_id: &site_id, + data: file_data, + filename: &file_name, + content_type: &content_type, created_by, storage, - &storage_provider, - ) + storage_provider: &storage_provider, + }) .await { Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), diff --git a/apps/backend/src/handlers/singleton_handler.rs b/apps/backend/src/handlers/singleton_handler.rs index bfbe8437..c7f10940 100644 --- a/apps/backend/src/handlers/singleton_handler.rs +++ b/apps/backend/src/handlers/singleton_handler.rs @@ -5,7 +5,6 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::Deserialize; -use serde_json::json; use std::sync::Arc; use tracing::instrument; @@ -14,6 +13,7 @@ pub struct SingletonSlug { slug: String, } +use crate::error::AppError; use crate::middleware::auth::{RequestContext, require_site_action}; use crate::models::authorization::Action; use crate::models::collection::{SingletonResponse, UpdateSingletonData}; @@ -24,14 +24,10 @@ use crate::storage::{StorageProvider, StorageRegistry}; fn get_storage_for_site( site_storage_provider: &str, registry: &StorageRegistry, -) -> Result, Response> { - registry.get(site_storage_provider).ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Storage not configured"})), - ) - .into_response() - }) +) -> Result, AppError> { + registry + .get(site_storage_provider) + .ok_or(AppError::Internal("Storage provider not found".into())) } #[utoipa::path( @@ -91,7 +87,7 @@ pub async fn get_singleton( .unwrap_or_else(|_| "filesystem".into()); let storage = match get_storage_for_site(&storage_provider, &storage_registry) { Ok(s) => s, - Err(resp) => return resp, + Err(e) => return e.into_response(), }; match services.singleton.get_singleton(&ctx.site_id, &slug, storage).await { diff --git a/apps/backend/src/mcp/server.rs b/apps/backend/src/mcp/server.rs index cabf7e9d..359f6106 100644 --- a/apps/backend/src/mcp/server.rs +++ b/apps/backend/src/mcp/server.rs @@ -1,653 +1,653 @@ -use std::sync::Arc; -use std::time::Instant; - -use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{ - CallToolRequestParams, CallToolResult, Implementation, InitializeRequestParams, ListResourcesResult, - ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, - ServerInfo, -}; -use rmcp::service::RequestContext; -use rmcp::service::RoleServer; -use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_handler, tool_router}; - -use crate::config::Config; -use crate::middleware::auth::Actor; -use crate::repository::Repository; -use crate::services::Services; -use crate::services::authorization::AuthorizationService; -use crate::storage::StorageRegistry; - -use crate::mcp::resources::site_schema; -use crate::mcp::tools::{collection, entry, file, singleton, site, webhook}; - -#[derive(Clone)] -pub struct CmsServer { - pub services: Arc, - pub repository: Arc, - pub storage_registry: Arc, - pub config: Arc, - pub authorizer: Arc, - stdio_token: Option>, -} - -#[tool_router] -impl CmsServer { - pub fn new( - services: Arc, - repository: Arc, - storage_registry: Arc, - config: Arc, - ) -> Self { - let authorizer = Arc::new(AuthorizationService::new(repository.user.clone())); - Self { - services, - repository, - storage_registry, - config, - authorizer, - stdio_token: None, - } - } - - pub fn new_stdio( - services: Arc, - repository: Arc, - storage_registry: Arc, - config: Arc, - token: String, - ) -> Self { - let mut server = Self::new(services, repository, storage_registry, config); - server.stdio_token = Some(Arc::from(token)); - server - } - - #[tool(description = "Get details of a specific site by ID")] - async fn get_site( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - site::get_site(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a site's name")] - async fn update_site( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - site::update_site(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List collections in a site")] - async fn list_collections( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::list_collections(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a collection by slug")] - async fn get_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::get_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Create a new collection")] - async fn create_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::create_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a collection's definition")] - async fn update_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::update_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete a collection")] - async fn delete_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::delete_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List entries in a site, optionally filtered by collection and status")] - async fn list_entries( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::list_entries(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get an entry by ID")] - async fn get_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::get_entry(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await - } - - #[tool(description = "Create a new entry")] - async fn create_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::create_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update an entry")] - async fn update_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::update_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete an entry")] - async fn delete_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::delete_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Publish an entry")] - async fn publish_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::publish_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Unpublish an entry")] - async fn unpublish_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::unpublish_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List singletons in a site")] - async fn list_singletons( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - singleton::list_singletons(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a singleton by slug")] - async fn get_singleton( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - singleton::get_singleton(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await - } - - #[tool(description = "Update a singleton's data")] - async fn update_singleton( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - singleton::update_singleton(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List files in a site")] - async fn list_files( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::list_files(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get file details by ID")] - async fn get_file( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::get_file(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Create a signed upload URL for uploading a file")] - async fn create_upload_url( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - let public_base_url = self.public_base_url(&ctx); - file::create_upload_url(&self.authorizer, &self.config, &actor, public_base_url, params).await - } - - #[tool(description = "Delete a file (soft delete)")] - async fn delete_file( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::delete_file(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List webhooks for a site")] - async fn list_webhooks( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::list_webhooks(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Create a webhook")] - async fn create_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::create_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Trigger a webhook")] - async fn trigger_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::trigger_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete a webhook")] - async fn delete_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::delete_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a webhook by ID")] - async fn get_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::get_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a webhook")] - async fn update_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::update_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List delivery attempts for a webhook")] - async fn list_webhook_deliveries( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::list_webhook_deliveries(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Restore a soft-deleted file")] - async fn restore_file( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - file::restore_file(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List revisions of an entry")] - async fn list_revisions( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::list_revisions(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Restore an entry to a previous revision")] - async fn restore_revision( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::restore_revision(&self.authorizer, &self.services, &actor, params).await - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use super::CmsServer; - use crate::mcp::schema::clean_input_schema; - - #[test] - fn tool_router_lists_registered_tools() { - let tools = CmsServer::tool_router().list_all(); - assert!(tools.iter().any(|tool| tool.name == "get_site")); - assert!(!tools.iter().any(|tool| tool.name.contains("token"))); - assert!(!tools.iter().any(|tool| tool.name == "list_sites")); - assert!(tools.len() > 15); - } - - fn all_tools() -> Vec { - CmsServer::tool_router().list_all() - } - - #[test] - fn no_type_null_in_schemas() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - assert_eq!( - cleaned.get("type").and_then(|v| v.as_str()), - Some("object"), - "tool '{}' input_schema must have type 'object'", - tool.name - ); - } - } - - #[test] - fn no_boolean_properties_in_schemas() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - if let Some(props) = cleaned.get("properties").and_then(|v| v.as_object()) { - for (key, value) in props { - assert!( - value.is_object() || value.is_array(), - "tool '{}' property '{}' must be a schema object, got {:?}", - tool.name, - key, - value - ); - } - } - } - } - - #[test] - fn all_input_schemas_are_valid_objects() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - assert!( - cleaned.get("type").and_then(|v| v.as_str()) == Some("object"), - "tool '{}' input_schema must have type 'object'", - tool.name - ); - let has_properties = cleaned.contains_key("properties"); - let has_required = cleaned.contains_key("required"); - assert!( - has_properties || !has_required, - "tool '{}' has 'required' but no 'properties'", - tool.name - ); - } - } - - #[test] - fn no_type_null_anywhere() { - for tool in all_tools() { - let schema_str = serde_json::to_string(&*tool.input_schema).unwrap(); - if schema_str.contains(r#""type":"null""#) { - panic!("tool '{}' still contains \"type\":\"null\"", tool.name,); - } - } - } - - #[test] - fn no_boolean_schema_values() { - for tool in all_tools() { - if let Some(props) = tool.input_schema.get("properties").and_then(|v| v.as_object()) { - for (key, value) in props { - if value.is_boolean() { - panic!("tool '{}' property '{}' is boolean {:?}", tool.name, key, value); - } - } - } - } - } - - #[test] - fn cleaned_schemas_have_no_schema_or_title() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - assert!( - !cleaned.contains_key("$schema"), - "tool '{}' still has $schema", - tool.name - ); - assert!(!cleaned.contains_key("title"), "tool '{}' still has title", tool.name); - } - } - - #[test] - fn required_fields_are_subset_of_properties() { - for tool in all_tools() { - let cleaned = clean_input_schema(tool.input_schema); - let props: HashSet<&str> = cleaned - .get("properties") - .and_then(|v| v.as_object()) - .map(|m| m.keys().map(|k| k.as_str()).collect()) - .unwrap_or_default(); - if let Some(required) = cleaned.get("required").and_then(|v| v.as_array()) { - for req in required { - let req_str = req.as_str().unwrap_or(""); - assert!( - props.contains(req_str), - "tool '{}' required field '{}' not in properties", - tool.name, - req_str - ); - } - } - } - } -} - -use crate::mcp::schema::clean_input_schema; - -#[tool_handler] -impl ServerHandler for CmsServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build()) - .with_server_info(Implementation::new("cms", env!("CARGO_PKG_VERSION"))) - } - - async fn initialize( - &self, - request: InitializeRequestParams, - mut context: RequestContext, - ) -> Result { - let started = Instant::now(); - self.authenticate_context(&mut context).await?; - if context.peer.peer_info().is_none() { - context.peer.set_peer_info(request.clone()); - } - tracing::info!( - mcp_method = "initialize", - duration_ms = started.elapsed().as_millis(), - outcome = "success", - "MCP operation completed" - ); - Ok(self.get_info().with_protocol_version(request.protocol_version)) - } - - async fn list_tools( - &self, - _request: Option, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - self.authenticate_context(&mut ctx).await?; - let tools = Self::tool_router() - .list_all() - .into_iter() - .map(|mut tool| { - tool.input_schema = clean_input_schema(tool.input_schema); - tool - }) - .collect(); - let result = ListToolsResult { - tools, - meta: None, - next_cursor: None, - }; - tracing::info!( - mcp_method = "tools/list", - duration_ms = started.elapsed().as_millis(), - outcome = "success", - "MCP operation completed" - ); - Ok(result) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - let tool_name = request.name.to_string(); - self.authenticate_context(&mut ctx).await?; - let tool_context = rmcp::handler::server::tool::ToolCallContext::new(self, request, ctx); - let result = Self::tool_router().call(tool_context).await; - tracing::info!( - mcp_method = "tools/call", - mcp_tool = %tool_name, - duration_ms = started.elapsed().as_millis(), - outcome = if result.is_ok() { "success" } else { "error" }, - "MCP operation completed" - ); - result - } - - async fn list_resources( - &self, - request: Option, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - let actor = self.authenticate_context(&mut ctx).await?; - let result = site_schema::list_resources(&self.authorizer, &self.services, &actor, request).await; - tracing::info!( - mcp_method = "resources/list", - duration_ms = started.elapsed().as_millis(), - outcome = if result.is_ok() { "success" } else { "error" }, - "MCP operation completed" - ); - result - } - - async fn read_resource( - &self, - request: ReadResourceRequestParams, - mut ctx: RequestContext, - ) -> Result { - let started = Instant::now(); - let actor = self.authenticate_context(&mut ctx).await?; - let result = site_schema::read_resource(&self.authorizer, &self.services, &actor, &request.uri).await; - tracing::info!( - mcp_method = "resources/read", - duration_ms = started.elapsed().as_millis(), - outcome = if result.is_ok() { "success" } else { "error" }, - "MCP operation completed" - ); - result - } -} - -impl CmsServer { - async fn authenticate_context(&self, ctx: &mut RequestContext) -> Result { - if let Some(token) = &self.stdio_token { - let actor = crate::mcp::auth::verify_stdio_token(token, &self.repository, &self.config.hmac_secret).await?; - ctx.extensions.insert(actor.clone()); - return Ok(actor); - } - - self.resolve_actor(ctx) - } - - fn resolve_actor(&self, ctx: &RequestContext) -> Result { - crate::mcp::auth::resolve_actor(ctx) - } - - fn public_base_url(&self, ctx: &RequestContext) -> Option { - if let Some(public_url) = &self.config.public_url { - return Some(public_url.clone()); - } - - let parts = ctx.extensions.get::()?; - let headers = &parts.headers; - let host = headers - .get("x-forwarded-host") - .or_else(|| headers.get("host"))? - .to_str() - .ok()?; - let proto = headers - .get("x-forwarded-proto") - .and_then(|value| value.to_str().ok()) - .unwrap_or("http"); - - Some(format!("{}://{}", proto, host).trim_end_matches('/').to_string()) - } -} +use std::sync::Arc; +use std::time::Instant; + +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Implementation, InitializeRequestParams, ListResourcesResult, + ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, + ServerInfo, +}; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_handler, tool_router}; + +use crate::config::Config; +use crate::middleware::auth::Actor; +use crate::repository::Repository; +use crate::services::Services; +use crate::services::authorization::AuthorizationService; +use crate::storage::StorageRegistry; + +use crate::mcp::resources::site_schema; +use crate::mcp::tools::{collection, entry, file, singleton, site, webhook}; + +#[derive(Clone)] +pub struct CmsServer { + pub services: Arc, + pub repository: Arc, + pub storage_registry: Arc, + pub config: Arc, + pub authorizer: Arc, + stdio_token: Option>, +} + +#[tool_router] +impl CmsServer { + pub fn new( + services: Arc, + repository: Arc, + storage_registry: Arc, + config: Arc, + ) -> Self { + let authorizer = Arc::new(AuthorizationService::new(repository.user.clone())); + Self { + services, + repository, + storage_registry, + config, + authorizer, + stdio_token: None, + } + } + + pub fn new_stdio( + services: Arc, + repository: Arc, + storage_registry: Arc, + config: Arc, + token: String, + ) -> Self { + let mut server = Self::new(services, repository, storage_registry, config); + server.stdio_token = Some(Arc::from(token)); + server + } + + #[tool(description = "Get details of a specific site by ID")] + async fn get_site( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + site::get_site(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update a site's name")] + async fn update_site( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + site::update_site(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List collections in a site")] + async fn list_collections( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::list_collections(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get a collection by slug")] + async fn get_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::get_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Create a new collection")] + async fn create_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::create_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update a collection's definition")] + async fn update_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::update_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Delete a collection")] + async fn delete_collection( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + collection::delete_collection(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List entries in a site, optionally filtered by collection and status")] + async fn list_entries( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::list_entries(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get an entry by ID")] + async fn get_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::get_entry(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await + } + + #[tool(description = "Create a new entry")] + async fn create_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::create_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update an entry")] + async fn update_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::update_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Delete an entry")] + async fn delete_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::delete_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Publish an entry")] + async fn publish_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::publish_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Unpublish an entry")] + async fn unpublish_entry( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::unpublish_entry(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List singletons in a site")] + async fn list_singletons( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + singleton::list_singletons(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get a singleton by slug")] + async fn get_singleton( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + singleton::get_singleton(&self.authorizer, &self.services, &self.storage_registry, &actor, params).await + } + + #[tool(description = "Update a singleton's data")] + async fn update_singleton( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + singleton::update_singleton(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List files in a site")] + async fn list_files( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::list_files(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get file details by ID")] + async fn get_file( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::get_file(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Create a signed upload URL for uploading a file")] + async fn create_upload_url( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + let public_base_url = self.public_base_url(&ctx); + file::create_upload_url(&self.authorizer, &self.config, &actor, public_base_url, params).await + } + + #[tool(description = "Delete a file (soft delete)")] + async fn delete_file( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::delete_file(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List webhooks for a site")] + async fn list_webhooks( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::list_webhooks(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Create a webhook")] + async fn create_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::create_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Trigger a webhook")] + async fn trigger_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::trigger_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Delete a webhook")] + async fn delete_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::delete_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Get a webhook by ID")] + async fn get_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::get_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Update a webhook")] + async fn update_webhook( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::update_webhook(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List delivery attempts for a webhook")] + async fn list_webhook_deliveries( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + webhook::list_webhook_deliveries(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Restore a soft-deleted file")] + async fn restore_file( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + file::restore_file(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "List revisions of an entry")] + async fn list_revisions( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::list_revisions(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Restore an entry to a previous revision")] + async fn restore_revision( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::restore_revision(&self.authorizer, &self.services, &actor, params).await + } +} + +use crate::mcp::schema::clean_input_schema; + +#[tool_handler] +impl ServerHandler for CmsServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build()) + .with_server_info(Implementation::new("cms", env!("CARGO_PKG_VERSION"))) + } + + async fn initialize( + &self, + request: InitializeRequestParams, + mut context: RequestContext, + ) -> Result { + let started = Instant::now(); + self.authenticate_context(&mut context).await?; + if context.peer.peer_info().is_none() { + context.peer.set_peer_info(request.clone()); + } + tracing::info!( + mcp_method = "initialize", + duration_ms = started.elapsed().as_millis(), + outcome = "success", + "MCP operation completed" + ); + Ok(self.get_info().with_protocol_version(request.protocol_version)) + } + + async fn list_tools( + &self, + _request: Option, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + self.authenticate_context(&mut ctx).await?; + let tools = Self::tool_router() + .list_all() + .into_iter() + .map(|mut tool| { + tool.input_schema = clean_input_schema(tool.input_schema); + tool + }) + .collect(); + let result = ListToolsResult { + tools, + meta: None, + next_cursor: None, + }; + tracing::info!( + mcp_method = "tools/list", + duration_ms = started.elapsed().as_millis(), + outcome = "success", + "MCP operation completed" + ); + Ok(result) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + let tool_name = request.name.to_string(); + self.authenticate_context(&mut ctx).await?; + let tool_context = rmcp::handler::server::tool::ToolCallContext::new(self, request, ctx); + let result = Self::tool_router().call(tool_context).await; + tracing::info!( + mcp_method = "tools/call", + mcp_tool = %tool_name, + duration_ms = started.elapsed().as_millis(), + outcome = if result.is_ok() { "success" } else { "error" }, + "MCP operation completed" + ); + result + } + + async fn list_resources( + &self, + request: Option, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + let actor = self.authenticate_context(&mut ctx).await?; + let result = site_schema::list_resources(&self.authorizer, &self.services, &actor, request).await; + tracing::info!( + mcp_method = "resources/list", + duration_ms = started.elapsed().as_millis(), + outcome = if result.is_ok() { "success" } else { "error" }, + "MCP operation completed" + ); + result + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + let actor = self.authenticate_context(&mut ctx).await?; + let result = site_schema::read_resource(&self.authorizer, &self.services, &actor, &request.uri).await; + tracing::info!( + mcp_method = "resources/read", + duration_ms = started.elapsed().as_millis(), + outcome = if result.is_ok() { "success" } else { "error" }, + "MCP operation completed" + ); + result + } +} + +impl CmsServer { + async fn authenticate_context(&self, ctx: &mut RequestContext) -> Result { + if let Some(token) = &self.stdio_token { + let actor = crate::mcp::auth::verify_stdio_token(token, &self.repository, &self.config.hmac_secret).await?; + ctx.extensions.insert(actor.clone()); + return Ok(actor); + } + + self.resolve_actor(ctx) + } + + fn resolve_actor(&self, ctx: &RequestContext) -> Result { + crate::mcp::auth::resolve_actor(ctx) + } + + fn public_base_url(&self, ctx: &RequestContext) -> Option { + if let Some(public_url) = &self.config.public_url { + return Some(public_url.clone()); + } + + let parts = ctx.extensions.get::()?; + let headers = &parts.headers; + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host"))? + .to_str() + .ok()?; + let proto = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .unwrap_or("http"); + + Some(format!("{}://{}", proto, host).trim_end_matches('/').to_string()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::CmsServer; + use crate::mcp::schema::clean_input_schema; + + #[test] + fn tool_router_lists_registered_tools() { + let tools = CmsServer::tool_router().list_all(); + assert!(tools.iter().any(|tool| tool.name == "get_site")); + assert!(!tools.iter().any(|tool| tool.name.contains("token"))); + assert!(!tools.iter().any(|tool| tool.name == "list_sites")); + assert!(tools.len() > 15); + } + + fn all_tools() -> Vec { + CmsServer::tool_router().list_all() + } + + #[test] + fn no_type_null_in_schemas() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + assert_eq!( + cleaned.get("type").and_then(|v| v.as_str()), + Some("object"), + "tool '{}' input_schema must have type 'object'", + tool.name + ); + } + } + + #[test] + fn no_boolean_properties_in_schemas() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + if let Some(props) = cleaned.get("properties").and_then(|v| v.as_object()) { + for (key, value) in props { + assert!( + value.is_object() || value.is_array(), + "tool '{}' property '{}' must be a schema object, got {:?}", + tool.name, + key, + value + ); + } + } + } + } + + #[test] + fn all_input_schemas_are_valid_objects() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + assert!( + cleaned.get("type").and_then(|v| v.as_str()) == Some("object"), + "tool '{}' input_schema must have type 'object'", + tool.name + ); + let has_properties = cleaned.contains_key("properties"); + let has_required = cleaned.contains_key("required"); + assert!( + has_properties || !has_required, + "tool '{}' has 'required' but no 'properties'", + tool.name + ); + } + } + + #[test] + fn no_type_null_anywhere() { + for tool in all_tools() { + let schema_str = serde_json::to_string(&*tool.input_schema).unwrap(); + if schema_str.contains(r#""type":"null""#) { + panic!("tool '{}' still contains \"type\":\"null\"", tool.name,); + } + } + } + + #[test] + fn no_boolean_schema_values() { + for tool in all_tools() { + if let Some(props) = tool.input_schema.get("properties").and_then(|v| v.as_object()) { + for (key, value) in props { + if value.is_boolean() { + panic!("tool '{}' property '{}' is boolean {:?}", tool.name, key, value); + } + } + } + } + } + + #[test] + fn cleaned_schemas_have_no_schema_or_title() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + assert!( + !cleaned.contains_key("$schema"), + "tool '{}' still has $schema", + tool.name + ); + assert!(!cleaned.contains_key("title"), "tool '{}' still has title", tool.name); + } + } + + #[test] + fn required_fields_are_subset_of_properties() { + for tool in all_tools() { + let cleaned = clean_input_schema(tool.input_schema); + let props: HashSet<&str> = cleaned + .get("properties") + .and_then(|v| v.as_object()) + .map(|m| m.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + if let Some(required) = cleaned.get("required").and_then(|v| v.as_array()) { + for req in required { + let req_str = req.as_str().unwrap_or(""); + assert!( + props.contains(req_str), + "tool '{}' required field '{}' not in properties", + tool.name, + req_str + ); + } + } + } + } +} diff --git a/apps/backend/src/mcp/tools/entry.rs b/apps/backend/src/mcp/tools/entry.rs index 4735abf4..8f045ce6 100644 --- a/apps/backend/src/mcp/tools/entry.rs +++ b/apps/backend/src/mcp/tools/entry.rs @@ -11,6 +11,7 @@ use crate::mcp::schema::ArbitraryJson; use crate::middleware::auth::Actor; use crate::models::authorization::Action; use crate::repository::traits::ListEntriesParams as RepoListEntriesParams; +use crate::services::entry::UpdateEntryInput; use crate::services::{Services, authorization::AuthorizationService}; use crate::storage::StorageRegistry; @@ -166,15 +167,15 @@ pub async fn update_entry( } match services .entry - .update_entry( - ¶ms.0.id, - &site_id, - params.0.values.as_ref(), - params.0.slug.as_deref(), - params.0.published.map(|b| if b { "published" } else { "draft" }), - None, - params.0.change_summary.as_deref(), - ) + .update_entry(UpdateEntryInput { + id: ¶ms.0.id, + site_id: &site_id, + data: params.0.values.as_ref(), + slug: params.0.slug.as_deref(), + status: params.0.published.map(|b| if b { "published" } else { "draft" }), + created_by: None, + change_summary: params.0.change_summary.as_deref(), + }) .await { Ok(entry) => ok_result(&entry), diff --git a/apps/backend/src/middleware/rate_limit.rs b/apps/backend/src/middleware/rate_limit.rs index 04dead58..decc06f4 100644 --- a/apps/backend/src/middleware/rate_limit.rs +++ b/apps/backend/src/middleware/rate_limit.rs @@ -39,7 +39,9 @@ impl RateLimiter { } } - pub fn check(&self, key: &str) -> Result<(), ()> { + /// Records a request against `key`'s bucket and returns whether it is allowed + /// (`true`) or has exceeded the limit for the current window (`false`). + pub fn check(&self, key: &str) -> bool { let now = Instant::now(); // Bound memory: drop expired buckets once the map grows large. Done @@ -60,11 +62,7 @@ impl RateLimiter { } entry.count += 1; - if entry.count > self.max_requests { - Err(()) - } else { - Ok(()) - } + entry.count <= self.max_requests } /// Derive the rate-limit bucket key for a request. When `trust_proxy_headers` @@ -110,12 +108,13 @@ pub async fn rate_limit_middleware( ) -> Response { let key = limiter.extract_client_key(&req); - match limiter.check(&key) { - Ok(()) => next.run(req).await, - Err(()) => ( + if limiter.check(&key) { + next.run(req).await + } else { + ( StatusCode::TOO_MANY_REQUESTS, Json(json!({"error": "Too many requests. Please try again later."})), ) - .into_response(), + .into_response() } } diff --git a/apps/backend/src/paths.rs b/apps/backend/src/paths.rs index bbc5af1c..22852347 100644 --- a/apps/backend/src/paths.rs +++ b/apps/backend/src/paths.rs @@ -26,10 +26,10 @@ pub const CMS_HOME_ENV: &str = "CMS_HOME"; /// `$CMS_HOME` wins if set and non-empty. Otherwise `~/.cms`. As a last resort /// (no detectable home directory) falls back to `.cms` in the current dir. pub fn home() -> PathBuf { - if let Some(value) = std::env::var_os(CMS_HOME_ENV) { - if !value.is_empty() { - return PathBuf::from(value); - } + if let Some(value) = std::env::var_os(CMS_HOME_ENV) + && !value.is_empty() + { + return PathBuf::from(value); } directories::BaseDirs::new() diff --git a/apps/backend/src/repository/mysql/access_token.rs b/apps/backend/src/repository/mysql/access_token.rs index 5aef9190..6c2dc25f 100644 --- a/apps/backend/src/repository/mysql/access_token.rs +++ b/apps/backend/src/repository/mysql/access_token.rs @@ -3,7 +3,7 @@ use sqlx::MySqlPool; use crate::models::access_token::AccessToken; use crate::repository::error::RepositoryError; -use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository}; +use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository, NewAccessToken}; pub struct MysqlAccessTokenRepository { pool: MySqlPool, @@ -29,17 +29,17 @@ impl AccessTokenRepository for MysqlAccessTokenRepository { Ok(rows) } - async fn create( - &self, - id: &str, - site_id: &str, - name: &str, - token_hash: &str, - token_prefix: &str, - token_hmac: &str, - permission: &str, - created_by_user_id: Option<&str>, - ) -> Result<(), RepositoryError> { + async fn create(&self, token: NewAccessToken<'_>) -> Result<(), RepositoryError> { + let NewAccessToken { + id, + site_id, + name, + token_hash, + token_prefix, + token_hmac, + permission, + created_by_user_id, + } = token; sqlx::query( "INSERT INTO access_tokens (id, site_id, name, token_hash, token_prefix, token_hmac, permission, created_by_user_id) diff --git a/apps/backend/src/repository/mysql/entry.rs b/apps/backend/src/repository/mysql/entry.rs index a7c72180..5e158d6f 100644 --- a/apps/backend/src/repository/mysql/entry.rs +++ b/apps/backend/src/repository/mysql/entry.rs @@ -7,7 +7,9 @@ use uuid::Uuid; use crate::models::entry::{Entry, EntryRevision}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{EntriesListResult, EntryRepository, ListEntriesParams, RevisionsListResult}; +use crate::repository::traits::{ + EntriesListResult, EntryRepository, ListEntriesParams, RevisionsListResult, UpdateEntryParams, +}; pub struct MysqlEntryRepository { pool: MySqlPool, @@ -224,16 +226,16 @@ impl EntryRepository for MysqlEntryRepository { self.get_by_id_any_site(id).await?.ok_or(RepositoryError::NotFound) } - async fn update( - &self, - id: &str, - site_id: &str, - data: &str, - slug: &str, - status: &str, - created_by: Option<&str>, - change_summary: Option<&str>, - ) -> Result { + async fn update(&self, params: UpdateEntryParams<'_>) -> Result { + let UpdateEntryParams { + id, + site_id, + data, + slug, + status, + created_by, + change_summary, + } = params; let mut tx = self.pool.begin().await?; sqlx::query( diff --git a/apps/backend/src/repository/mysql/file.rs b/apps/backend/src/repository/mysql/file.rs index 403845ba..4d1807dd 100644 --- a/apps/backend/src/repository/mysql/file.rs +++ b/apps/backend/src/repository/mysql/file.rs @@ -3,7 +3,7 @@ use sqlx::MySqlPool; use crate::models::file::{File, FileReference}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams}; +use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams, NewFile}; pub struct MysqlFileRepository { pool: MySqlPool, @@ -116,21 +116,21 @@ impl FileRepository for MysqlFileRepository { }) } - async fn create( - &self, - id: &str, - site_id: &str, - filename: &str, - original_name: &str, - mime_type: &str, - size: i64, - storage_provider: &str, - storage_key: &str, - thumbnail_key: Option<&str>, - width: Option, - height: Option, - created_by: Option<&str>, - ) -> Result { + async fn create(&self, file: NewFile<'_>) -> Result { + let NewFile { + id, + site_id, + filename, + original_name, + mime_type, + size, + storage_provider, + storage_key, + thumbnail_key, + width, + height, + created_by, + } = file; sqlx::query( "INSERT INTO files (id, site_id, filename, original_name, mime_type, size, storage_provider, storage_key, thumbnail_key, width, height, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) diff --git a/apps/backend/src/repository/mysql/webhook.rs b/apps/backend/src/repository/mysql/webhook.rs index f66ecfc1..e700fc89 100644 --- a/apps/backend/src/repository/mysql/webhook.rs +++ b/apps/backend/src/repository/mysql/webhook.rs @@ -3,7 +3,7 @@ use sqlx::MySqlPool; use crate::models::webhook::{SiteWebhook, WebhookDelivery}; use crate::repository::error::RepositoryError; -use crate::repository::traits::WebhookRepository; +use crate::repository::traits::{NewWebhookDelivery, WebhookRepository}; pub struct MysqlWebhookRepository { pool: MySqlPool, @@ -98,16 +98,16 @@ impl WebhookRepository for MysqlWebhookRepository { Ok(result.rows_affected()) } - async fn create_delivery( - &self, - id: &str, - webhook_id: &str, - status: &str, - status_code: Option, - response_body: Option<&str>, - duration_ms: Option, - triggered_by: Option<&str>, - ) -> Result { + async fn create_delivery(&self, delivery: NewWebhookDelivery<'_>) -> Result { + let NewWebhookDelivery { + id, + webhook_id, + status, + status_code, + response_body, + duration_ms, + triggered_by, + } = delivery; sqlx::query( "INSERT INTO site_webhook_deliveries (id, webhook_id, status, status_code, response_body, duration_ms, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?)", ) diff --git a/apps/backend/src/repository/postgres/access_token.rs b/apps/backend/src/repository/postgres/access_token.rs index 7ee8e826..025dda33 100644 --- a/apps/backend/src/repository/postgres/access_token.rs +++ b/apps/backend/src/repository/postgres/access_token.rs @@ -3,7 +3,7 @@ use sqlx::PgPool; use crate::models::access_token::AccessToken; use crate::repository::error::RepositoryError; -use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository}; +use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository, NewAccessToken}; pub struct PostgresAccessTokenRepository { pool: PgPool, @@ -29,17 +29,17 @@ impl AccessTokenRepository for PostgresAccessTokenRepository { Ok(rows) } - async fn create( - &self, - id: &str, - site_id: &str, - name: &str, - token_hash: &str, - token_prefix: &str, - token_hmac: &str, - permission: &str, - created_by_user_id: Option<&str>, - ) -> Result<(), RepositoryError> { + async fn create(&self, token: NewAccessToken<'_>) -> Result<(), RepositoryError> { + let NewAccessToken { + id, + site_id, + name, + token_hash, + token_prefix, + token_hmac, + permission, + created_by_user_id, + } = token; sqlx::query( "INSERT INTO access_tokens (id, site_id, name, token_hash, token_prefix, token_hmac, permission, created_by_user_id) diff --git a/apps/backend/src/repository/postgres/entry.rs b/apps/backend/src/repository/postgres/entry.rs index d0feee94..a8cb9280 100644 --- a/apps/backend/src/repository/postgres/entry.rs +++ b/apps/backend/src/repository/postgres/entry.rs @@ -7,7 +7,9 @@ use uuid::Uuid; use crate::models::entry::{Entry, EntryRevision}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{EntriesListResult, EntryRepository, ListEntriesParams, RevisionsListResult}; +use crate::repository::traits::{ + EntriesListResult, EntryRepository, ListEntriesParams, RevisionsListResult, UpdateEntryParams, +}; pub struct PostgresEntryRepository { pool: PgPool, @@ -236,16 +238,16 @@ impl EntryRepository for PostgresEntryRepository { self.get_by_id_any_site(id).await?.ok_or(RepositoryError::NotFound) } - async fn update( - &self, - id: &str, - site_id: &str, - data: &str, - slug: &str, - status: &str, - created_by: Option<&str>, - change_summary: Option<&str>, - ) -> Result { + async fn update(&self, params: UpdateEntryParams<'_>) -> Result { + let UpdateEntryParams { + id, + site_id, + data, + slug, + status, + created_by, + change_summary, + } = params; let mut tx = self.pool.begin().await?; sqlx::query( diff --git a/apps/backend/src/repository/postgres/file.rs b/apps/backend/src/repository/postgres/file.rs index a53b95d1..3061b8b0 100644 --- a/apps/backend/src/repository/postgres/file.rs +++ b/apps/backend/src/repository/postgres/file.rs @@ -3,7 +3,7 @@ use sqlx::PgPool; use crate::models::file::{File, FileReference}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams}; +use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams, NewFile}; pub struct PostgresFileRepository { pool: PgPool, @@ -131,21 +131,21 @@ impl FileRepository for PostgresFileRepository { }) } - async fn create( - &self, - id: &str, - site_id: &str, - filename: &str, - original_name: &str, - mime_type: &str, - size: i64, - storage_provider: &str, - storage_key: &str, - thumbnail_key: Option<&str>, - width: Option, - height: Option, - created_by: Option<&str>, - ) -> Result { + async fn create(&self, file: NewFile<'_>) -> Result { + let NewFile { + id, + site_id, + filename, + original_name, + mime_type, + size, + storage_provider, + storage_key, + thumbnail_key, + width, + height, + created_by, + } = file; sqlx::query( "INSERT INTO files (id, site_id, filename, original_name, mime_type, size, storage_provider, storage_key, thumbnail_key, width, height, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", ) diff --git a/apps/backend/src/repository/postgres/webhook.rs b/apps/backend/src/repository/postgres/webhook.rs index acaa5555..56e45f41 100644 --- a/apps/backend/src/repository/postgres/webhook.rs +++ b/apps/backend/src/repository/postgres/webhook.rs @@ -3,7 +3,7 @@ use sqlx::PgPool; use crate::models::webhook::{SiteWebhook, WebhookDelivery}; use crate::repository::error::RepositoryError; -use crate::repository::traits::WebhookRepository; +use crate::repository::traits::{NewWebhookDelivery, WebhookRepository}; pub struct PostgresWebhookRepository { pool: PgPool, @@ -100,16 +100,16 @@ impl WebhookRepository for PostgresWebhookRepository { Ok(result.rows_affected()) } - async fn create_delivery( - &self, - id: &str, - webhook_id: &str, - status: &str, - status_code: Option, - response_body: Option<&str>, - duration_ms: Option, - triggered_by: Option<&str>, - ) -> Result { + async fn create_delivery(&self, delivery: NewWebhookDelivery<'_>) -> Result { + let NewWebhookDelivery { + id, + webhook_id, + status, + status_code, + response_body, + duration_ms, + triggered_by, + } = delivery; sqlx::query( "INSERT INTO site_webhook_deliveries (id, webhook_id, status, status_code, response_body, duration_ms, triggered_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", ) diff --git a/apps/backend/src/repository/sqlite/access_token.rs b/apps/backend/src/repository/sqlite/access_token.rs index 8d85a79b..c169b5f5 100644 --- a/apps/backend/src/repository/sqlite/access_token.rs +++ b/apps/backend/src/repository/sqlite/access_token.rs @@ -3,7 +3,7 @@ use sqlx::SqlitePool; use crate::models::access_token::AccessToken; use crate::repository::error::RepositoryError; -use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository}; +use crate::repository::traits::{AccessTokenLookupRow, AccessTokenRepository, NewAccessToken}; pub struct SqliteAccessTokenRepository { pool: SqlitePool, @@ -29,17 +29,17 @@ impl AccessTokenRepository for SqliteAccessTokenRepository { Ok(rows) } - async fn create( - &self, - id: &str, - site_id: &str, - name: &str, - token_hash: &str, - token_prefix: &str, - token_hmac: &str, - permission: &str, - created_by_user_id: Option<&str>, - ) -> Result<(), RepositoryError> { + async fn create(&self, token: NewAccessToken<'_>) -> Result<(), RepositoryError> { + let NewAccessToken { + id, + site_id, + name, + token_hash, + token_prefix, + token_hmac, + permission, + created_by_user_id, + } = token; sqlx::query( "INSERT INTO access_tokens (id, site_id, name, token_hash, token_prefix, token_hmac, permission, created_by_user_id) diff --git a/apps/backend/src/repository/sqlite/entry.rs b/apps/backend/src/repository/sqlite/entry.rs index 67958405..dcdd3c1c 100644 --- a/apps/backend/src/repository/sqlite/entry.rs +++ b/apps/backend/src/repository/sqlite/entry.rs @@ -8,7 +8,9 @@ use uuid::Uuid; use crate::models::entry::{Entry, EntryRevision}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{EntriesListResult, EntryRepository, ListEntriesParams, RevisionsListResult}; +use crate::repository::traits::{ + EntriesListResult, EntryRepository, ListEntriesParams, RevisionsListResult, UpdateEntryParams, +}; static FILE_URL_RE: LazyLock = LazyLock::new(|| Regex::new(r"/api/files/([^/]+)(?:/thumbnail)?").unwrap()); @@ -273,16 +275,16 @@ impl EntryRepository for SqliteEntryRepository { self.get_by_id_any_site(id).await?.ok_or(RepositoryError::NotFound) } - async fn update( - &self, - id: &str, - site_id: &str, - data: &str, - slug: &str, - status: &str, - created_by: Option<&str>, - change_summary: Option<&str>, - ) -> Result { + async fn update(&self, params: UpdateEntryParams<'_>) -> Result { + let UpdateEntryParams { + id, + site_id, + data, + slug, + status, + created_by, + change_summary, + } = params; let mut tx = self.pool.begin().await?; let next_number: i64 = diff --git a/apps/backend/src/repository/sqlite/file.rs b/apps/backend/src/repository/sqlite/file.rs index 016e8cdf..c8a84704 100644 --- a/apps/backend/src/repository/sqlite/file.rs +++ b/apps/backend/src/repository/sqlite/file.rs @@ -3,7 +3,7 @@ use sqlx::SqlitePool; use crate::models::file::{File, FileReference}; use crate::repository::error::RepositoryError; -use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams}; +use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams, NewFile}; pub struct SqliteFileRepository { pool: SqlitePool, @@ -125,21 +125,21 @@ impl FileRepository for SqliteFileRepository { }) } - async fn create( - &self, - id: &str, - site_id: &str, - filename: &str, - original_name: &str, - mime_type: &str, - size: i64, - storage_provider: &str, - storage_key: &str, - thumbnail_key: Option<&str>, - width: Option, - height: Option, - created_by: Option<&str>, - ) -> Result { + async fn create(&self, file: NewFile<'_>) -> Result { + let NewFile { + id, + site_id, + filename, + original_name, + mime_type, + size, + storage_provider, + storage_key, + thumbnail_key, + width, + height, + created_by, + } = file; sqlx::query( "INSERT INTO files (id, site_id, filename, original_name, mime_type, size, storage_provider, storage_key, thumbnail_key, width, height, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) diff --git a/apps/backend/src/repository/sqlite/webhook.rs b/apps/backend/src/repository/sqlite/webhook.rs index 7be86de3..3aea7246 100644 --- a/apps/backend/src/repository/sqlite/webhook.rs +++ b/apps/backend/src/repository/sqlite/webhook.rs @@ -3,7 +3,7 @@ use sqlx::SqlitePool; use crate::models::webhook::{SiteWebhook, WebhookDelivery}; use crate::repository::error::RepositoryError; -use crate::repository::traits::WebhookRepository; +use crate::repository::traits::{NewWebhookDelivery, WebhookRepository}; pub struct SqliteWebhookRepository { pool: SqlitePool, @@ -98,16 +98,16 @@ impl WebhookRepository for SqliteWebhookRepository { Ok(result.rows_affected()) } - async fn create_delivery( - &self, - id: &str, - webhook_id: &str, - status: &str, - status_code: Option, - response_body: Option<&str>, - duration_ms: Option, - triggered_by: Option<&str>, - ) -> Result { + async fn create_delivery(&self, delivery: NewWebhookDelivery<'_>) -> Result { + let NewWebhookDelivery { + id, + webhook_id, + status, + status_code, + response_body, + duration_ms, + triggered_by, + } = delivery; sqlx::query( "INSERT INTO site_webhook_deliveries (id, webhook_id, status, status_code, response_body, duration_ms, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?)", ) diff --git a/apps/backend/src/repository/traits.rs b/apps/backend/src/repository/traits.rs index bd8aded1..51cdc909 100644 --- a/apps/backend/src/repository/traits.rs +++ b/apps/backend/src/repository/traits.rs @@ -106,6 +106,17 @@ pub trait CollectionRepository: Send + Sync { ) -> Result<(), RepositoryError>; } +/// Fields for an entry update (see [`EntryRepository::update`]). +pub struct UpdateEntryParams<'a> { + pub id: &'a str, + pub site_id: &'a str, + pub data: &'a str, + pub slug: &'a str, + pub status: &'a str, + pub created_by: Option<&'a str>, + pub change_summary: Option<&'a str>, +} + #[async_trait] pub trait EntryRepository: Send + Sync { async fn get_by_id(&self, id: &str, site_id: &str, published_only: bool) -> Result, RepositoryError>; @@ -145,16 +156,7 @@ pub trait EntryRepository: Send + Sync { created_by: Option<&str>, change_summary: Option<&str>, ) -> Result; - async fn update( - &self, - id: &str, - site_id: &str, - data: &str, - slug: &str, - status: &str, - created_by: Option<&str>, - change_summary: Option<&str>, - ) -> Result; + async fn update(&self, params: UpdateEntryParams<'_>) -> Result; async fn delete(&self, id: &str, site_id: &str) -> Result; async fn publish(&self, id: &str, site_id: &str) -> Result; async fn unpublish(&self, id: &str, site_id: &str) -> Result; @@ -229,26 +231,28 @@ pub struct FileListResult { pub per_page: i64, } +/// A new file record to persist (see [`FileRepository::create`]). +pub struct NewFile<'a> { + pub id: &'a str, + pub site_id: &'a str, + pub filename: &'a str, + pub original_name: &'a str, + pub mime_type: &'a str, + pub size: i64, + pub storage_provider: &'a str, + pub storage_key: &'a str, + pub thumbnail_key: Option<&'a str>, + pub width: Option, + pub height: Option, + pub created_by: Option<&'a str>, +} + #[async_trait] pub trait FileRepository: Send + Sync { async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError>; async fn get_by_id_any(&self, id: &str) -> Result, RepositoryError>; async fn list(&self, params: ListFilesParams<'_>) -> Result; - async fn create( - &self, - id: &str, - site_id: &str, - filename: &str, - original_name: &str, - mime_type: &str, - size: i64, - storage_provider: &str, - storage_key: &str, - thumbnail_key: Option<&str>, - width: Option, - height: Option, - created_by: Option<&str>, - ) -> Result; + async fn create(&self, file: NewFile<'_>) -> Result; async fn soft_delete(&self, id: &str, site_id: &str) -> Result; async fn restore(&self, id: &str, site_id: &str) -> Result; async fn batch_soft_delete(&self, site_id: &str, ids: &[String]) -> Result; @@ -275,25 +279,38 @@ pub type AccessTokenLookupRow = ( String, ); +/// A new access token to persist (see [`AccessTokenRepository::create`]). +pub struct NewAccessToken<'a> { + pub id: &'a str, + pub site_id: &'a str, + pub name: &'a str, + pub token_hash: &'a str, + pub token_prefix: &'a str, + pub token_hmac: &'a str, + pub permission: &'a str, + pub created_by_user_id: Option<&'a str>, +} + #[async_trait] pub trait AccessTokenRepository: Send + Sync { async fn list(&self, site_id: &str) -> Result, RepositoryError>; - async fn create( - &self, - id: &str, - site_id: &str, - name: &str, - token_hash: &str, - token_prefix: &str, - token_hmac: &str, - permission: &str, - created_by_user_id: Option<&str>, - ) -> Result<(), RepositoryError>; + async fn create(&self, token: NewAccessToken<'_>) -> Result<(), RepositoryError>; async fn delete(&self, id: &str, site_id: &str) -> Result; async fn find_by_prefix(&self, prefix: &str) -> Result, RepositoryError>; async fn update_last_used(&self, id: &str) -> Result<(), RepositoryError>; } +/// A webhook delivery record to insert (see [`WebhookRepository::create_delivery`]). +pub struct NewWebhookDelivery<'a> { + pub id: &'a str, + pub webhook_id: &'a str, + pub status: &'a str, + pub status_code: Option, + pub response_body: Option<&'a str>, + pub duration_ms: Option, + pub triggered_by: Option<&'a str>, +} + #[async_trait] pub trait WebhookRepository: Send + Sync { async fn list_for_site(&self, site_id: &str) -> Result, RepositoryError>; @@ -315,16 +332,7 @@ pub trait WebhookRepository: Send + Sync { headers_encrypted: Option<&str>, ) -> Result; async fn delete(&self, id: &str, site_id: &str) -> Result; - async fn create_delivery( - &self, - id: &str, - webhook_id: &str, - status: &str, - status_code: Option, - response_body: Option<&str>, - duration_ms: Option, - triggered_by: Option<&str>, - ) -> Result; + async fn create_delivery(&self, delivery: NewWebhookDelivery<'_>) -> Result; async fn list_deliveries( &self, webhook_id: &str, diff --git a/apps/backend/src/services/access_token.rs b/apps/backend/src/services/access_token.rs index fe41ccf7..6a6f0530 100644 --- a/apps/backend/src/services/access_token.rs +++ b/apps/backend/src/services/access_token.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::middleware::auth::compute_key_hmac; use crate::models::access_token::{AccessToken, AccessTokenPermission, AccessTokenResponse}; -use crate::repository::traits::AccessTokenRepository; +use crate::repository::traits::{AccessTokenRepository, NewAccessToken}; const SITE_TOKEN_PREFIX: &str = "cms_site_"; @@ -91,16 +91,16 @@ impl AccessTokenService { let permission_str = permission.as_str(); self.access_token_repo - .create( - &id, + .create(NewAccessToken { + id: &id, site_id, name, - &token_hash, - &prefix, - &token_hmac, - permission_str, - created_by, - ) + token_hash: &token_hash, + token_prefix: &prefix, + token_hmac: &token_hmac, + permission: permission_str, + created_by_user_id: created_by, + }) .await .map_err(|e| { error!("Failed to create site token: site_id={}, error={}", site_id, e); diff --git a/apps/backend/src/services/backup/mod.rs b/apps/backend/src/services/backup/mod.rs index 230ca421..7f24e0bd 100644 --- a/apps/backend/src/services/backup/mod.rs +++ b/apps/backend/src/services/backup/mod.rs @@ -277,10 +277,10 @@ impl BackupService { } // Retention pruning for scheduled backups. - if let Some(sched_id) = &opts.schedule_id { - if let Ok(Some(sched)) = meta::get_schedule(&self.pool, sched_id).await { - let _ = self.prune_retention(sched_id, sched.retention_n).await; - } + if let Some(sched_id) = &opts.schedule_id + && let Ok(Some(sched)) = meta::get_schedule(&self.pool, sched_id).await + { + let _ = self.prune_retention(sched_id, sched.retention_n).await; } meta::get_backup(&self.pool, &id).await?.ok_or(BackupError::NotFound) @@ -307,10 +307,10 @@ impl BackupService { /// Delete a backup artifact and its row. pub async fn delete_backup(&self, id: &str) -> Result<(), BackupError> { - if let Some(row) = meta::get_backup(&self.pool, id).await? { - if let Some(key) = &row.destination_key { - let _ = self.destination.delete(key).await; - } + if let Some(row) = meta::get_backup(&self.pool, id).await? + && let Some(key) = &row.destination_key + { + let _ = self.destination.delete(key).await; } meta::delete_backup_row(&self.pool, id).await } @@ -350,10 +350,10 @@ impl BackupService { if let Some(Some(k)) = row.get(key_i) { keys.push(k.clone()); } - if let Some(ti) = thumb_i { - if let Some(Some(tk)) = row.get(ti) { - keys.push(tk.clone()); - } + if let Some(ti) = thumb_i + && let Some(Some(tk)) = row.get(ti) + { + keys.push(tk.clone()); } for key in keys { if !seen.insert(key.clone()) { @@ -470,7 +470,7 @@ impl BackupService { build_instance_plan(self.pool.backend(), &tables) } RestoreTarget::Site { site_id, import_as_new } => { - self.build_site_restore_plan(&manifest, &tables, &[site_id.clone()], *import_as_new, &req) + self.build_site_restore_plan(&manifest, &tables, std::slice::from_ref(site_id), *import_as_new, &req) .await? } RestoreTarget::Sites { @@ -606,10 +606,7 @@ impl BackupService { } /// Decrypt/decompress an artifact into (manifest, table NDJSON, file blobs). - fn open( - &self, - bytes: &[u8], - ) -> Result<(Manifest, HashMap>, HashMap>), BackupError> { + fn open(&self, bytes: &[u8]) -> Result { if bytes.len() < MAGIC.len() + 1 || &bytes[..MAGIC.len()] != MAGIC { return Err(BackupError::Invalid("not a CMS backup artifact".into())); } @@ -747,6 +744,10 @@ fn inserts_for(backend: crate::database::backend::DatabaseBackend, tables: &Tabl type Row = serde_json::Map; type Tables = HashMap>; +/// 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>); + fn parse_all_tables(ndjson: &HashMap>) -> Result { let mut tables = Tables::new(); for (name, bytes) in ndjson { @@ -857,10 +858,10 @@ fn reconcile_user_refs(tables: &mut Tables, existing: &HashSet, fallback for (table, col) in nullable { if let Some(rows) = tables.get_mut(table) { for m in rows.iter_mut() { - if let Some(u) = str_field(m, col) { - if !existing.contains(u) { - m.insert(col.to_string(), serde_json::Value::Null); - } + if let Some(u) = str_field(m, col) + && !existing.contains(u) + { + m.insert(col.to_string(), serde_json::Value::Null); } } } @@ -871,10 +872,8 @@ fn reconcile_user_refs(tables: &mut Tables, existing: &HashSet, fallback let missing = str_field(m, "created_by") .map(|u| !existing.contains(u)) .unwrap_or(true); - if missing { - if let Some(fb) = fallback_user { - m.insert("created_by".to_string(), serde_json::Value::String(fb.to_string())); - } + if missing && let Some(fb) = fallback_user { + m.insert("created_by".to_string(), serde_json::Value::String(fb.to_string())); } } } @@ -911,10 +910,10 @@ fn remap_ids(tables: &mut Tables) { } let remap = |row: &mut Row, col: &str, map: &HashMap| { - if let Some(old) = str_field(row, col).map(String::from) { - if let Some(new) = map.get(&old) { - row.insert(col.to_string(), serde_json::Value::String(new.clone())); - } + if let Some(old) = str_field(row, col).map(String::from) + && let Some(new) = map.get(&old) + { + row.insert(col.to_string(), serde_json::Value::String(new.clone())); } }; let sites = maps["sites"].clone(); @@ -955,10 +954,10 @@ fn remap_ids(tables: &mut Tables) { } fn pick_fallback_user(actor: Option<&str>, existing: &HashSet) -> Option { - if let Some(a) = actor { - if existing.contains(a) { - return Some(a.to_string()); - } + if let Some(a) = actor + && existing.contains(a) + { + return Some(a.to_string()); } existing.iter().next().cloned() } @@ -1012,7 +1011,7 @@ fn append_entry(builder: &mut tar::Builder>, path: &str, data: &[u8]) -> .map_err(|e| BackupError::Io(e.to_string())) } -fn read_tar(tar_bytes: &[u8]) -> Result<(Manifest, HashMap>, HashMap>), BackupError> { +fn read_tar(tar_bytes: &[u8]) -> Result { let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); let mut manifest: Option = None; let mut tables = HashMap::new(); diff --git a/apps/backend/src/services/entry.rs b/apps/backend/src/services/entry.rs index a04c6150..9578f753 100644 --- a/apps/backend/src/services/entry.rs +++ b/apps/backend/src/services/entry.rs @@ -11,6 +11,7 @@ use crate::models::entry::{Entry, EntryRevision}; use crate::repository::error::RepositoryError; use crate::repository::traits::{ CollectionRepository, EntriesListResult, EntryRepository, FileRepository, ListEntriesParams, RevisionsListResult, + UpdateEntryParams, }; use crate::services::search::queue::{OP_DELETE, OP_INDEX, SearchQueue}; use crate::services::search::{SearchError, SearchParams, SearchService}; @@ -27,6 +28,18 @@ pub struct EntryService { search_queue: Option>, } +/// Fields for [`EntryService::update_entry`]. All but `id`/`site_id` are optional; +/// `None` leaves the existing value unchanged. +pub struct UpdateEntryInput<'a> { + pub id: &'a str, + pub site_id: &'a str, + pub data: Option<&'a Value>, + pub slug: Option<&'a str>, + pub status: Option<&'a str>, + pub created_by: Option<&'a str>, + pub change_summary: Option<&'a str>, +} + #[derive(Error, Debug)] pub enum EntryError { #[error("Not found")] @@ -280,16 +293,16 @@ impl EntryService { } } - pub async fn update_entry( - &self, - id: &str, - site_id: &str, - data: Option<&Value>, - slug: Option<&str>, - status: Option<&str>, - created_by: Option<&str>, - change_summary: Option<&str>, - ) -> Result { + pub async fn update_entry(&self, input: UpdateEntryInput<'_>) -> Result { + let UpdateEntryInput { + id, + site_id, + data, + slug, + status, + created_by, + change_summary, + } = input; debug!("Updating entry: id={}, site_id={}", id, site_id); let existing = self @@ -333,15 +346,15 @@ impl EntryService { ); self.entry_repo - .update( + .update(UpdateEntryParams { id, site_id, - &data_str, - final_slug, - final_status, + data: &data_str, + slug: final_slug, + status: final_status, created_by, change_summary, - ) + }) .await .map_err(|e| { error!( @@ -661,15 +674,15 @@ mod tests { let new_data = json!({"title": "Updated Title"}); let result = service - .update_entry( - "entry-123", - "site-123", - Some(&new_data), - Some("updated-slug"), - None, - None, - None, - ) + .update_entry(UpdateEntryInput { + id: "entry-123", + site_id: "site-123", + data: Some(&new_data), + slug: Some("updated-slug"), + status: None, + created_by: None, + change_summary: None, + }) .await; assert!(result.is_ok()); let entry = result.unwrap(); @@ -683,7 +696,15 @@ mod tests { let service = EntryService::new(entry_repo, file_repo, test_collection_repo()); let result = service - .update_entry("nonexistent", "site-123", Some(&json!({})), None, None, None, None) + .update_entry(UpdateEntryInput { + id: "nonexistent", + site_id: "site-123", + data: Some(&json!({})), + slug: None, + status: None, + created_by: None, + change_summary: None, + }) .await; assert!(matches!(result, Err(EntryError::NotFound))); } @@ -696,7 +717,15 @@ mod tests { let service = EntryService::new(entry_repo, file_repo, test_collection_repo()); let result = service - .update_entry("entry-123", "site-123", None, None, Some("published"), None, None) + .update_entry(UpdateEntryInput { + id: "entry-123", + site_id: "site-123", + data: None, + slug: None, + status: Some("published"), + created_by: None, + change_summary: None, + }) .await; assert!(result.is_ok()); assert_eq!(result.unwrap().status, "published"); @@ -945,7 +974,15 @@ mod tests { let updated = json!({"title": "Updated"}); service - .update_entry(&entry.id, "site-123", Some(&updated), None, None, None, None) + .update_entry(UpdateEntryInput { + id: &entry.id, + site_id: "site-123", + data: Some(&updated), + slug: None, + status: None, + created_by: None, + change_summary: None, + }) .await .unwrap(); @@ -1026,7 +1063,15 @@ mod tests { let service = EntryService::new(entry_repo, file_repo, col_repo); let data = json!({"title": "not-a-number"}); let result = service - .update_entry("entry-123", "site-123", Some(&data), None, None, None, None) + .update_entry(UpdateEntryInput { + id: "entry-123", + site_id: "site-123", + data: Some(&data), + slug: None, + status: None, + created_by: None, + change_summary: None, + }) .await; assert!(matches!(result, Err(EntryError::ValidationFailed(_)))); } diff --git a/apps/backend/src/services/file.rs b/apps/backend/src/services/file.rs index 9348f082..eca303fd 100644 --- a/apps/backend/src/services/file.rs +++ b/apps/backend/src/services/file.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::config::Config; use crate::models::file::{File, FileReference, FileWithUrl}; -use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams}; +use crate::repository::traits::{FileListResult, FileRepository, ListFilesParams, NewFile}; use crate::storage::StorageProvider; #[derive(Clone)] @@ -19,6 +19,17 @@ pub struct FileService { config: Arc, } +/// Inputs for [`FileService::upload_file`]. +pub struct UploadFileRequest<'a> { + pub site_id: &'a str, + pub data: Bytes, + pub filename: &'a str, + pub content_type: &'a str, + pub created_by: Option<&'a str>, + pub storage: Arc, + pub storage_provider: &'a str, +} + #[derive(Error, Debug)] pub enum FileError { #[error("Not found")] @@ -97,16 +108,16 @@ impl FileService { .map_err(|e| FileError::DatabaseError(e.to_string())) } - pub async fn upload_file( - &self, - site_id: &str, - data: Bytes, - filename: &str, - content_type: &str, - created_by: Option<&str>, - storage: Arc, - storage_provider: &str, - ) -> Result { + pub async fn upload_file(&self, req: UploadFileRequest<'_>) -> Result { + let UploadFileRequest { + site_id, + data, + filename, + content_type, + created_by, + storage, + storage_provider, + } = req; info!( "Uploading file: site_id={}, content_type={}, size={} bytes", site_id, @@ -228,20 +239,20 @@ impl FileService { debug!("Creating file record in repository: id={}", file_id); let file = self .file_repo - .create( - &file_id, + .create(NewFile { + id: &file_id, site_id, - &generated_filename, - &original_name, - &mime_type, - file_size, + filename: &generated_filename, + original_name: &original_name, + mime_type: &mime_type, + size: file_size, storage_provider, - &storage_key, - thumb_key_str, + storage_key: &storage_key, + thumbnail_key: thumb_key_str, width, height, created_by, - ) + }) .await .map_err(|e| { error!( @@ -595,7 +606,15 @@ mod tests { let data = Bytes::from(&[0u8; 200][..]); let result = service - .upload_file("site-123", data, "test.txt", "text/plain", None, storage, "filesystem") + .upload_file(UploadFileRequest { + site_id: "site-123", + data, + filename: "test.txt", + content_type: "text/plain", + created_by: None, + storage, + storage_provider: "filesystem", + }) .await; assert!(matches!(result, Err(FileError::FileTooLarge(_)))); diff --git a/apps/backend/src/services/search/schema.rs b/apps/backend/src/services/search/schema.rs index b4204575..12695abc 100644 --- a/apps/backend/src/services/search/schema.rs +++ b/apps/backend/src/services/search/schema.rs @@ -10,7 +10,7 @@ //! - `status` — exact-match filter (`draft` / `published`). //! - `slug` — tokenized + stored, lightly searchable. //! - `body` — the flattened, tokenized text of all scalar values in -//! `entries.data`; the main ranked field. +//! `entries.data`; the main ranked field. //! //! `body`/`slug` use an English-stemming analyzer so "running" matches "run". diff --git a/apps/backend/src/services/webhook.rs b/apps/backend/src/services/webhook.rs index 9495c32c..fc30bf0f 100644 --- a/apps/backend/src/services/webhook.rs +++ b/apps/backend/src/services/webhook.rs @@ -17,7 +17,7 @@ use uuid::Uuid; use crate::models::webhook::{SiteWebhook, WebhookDelivery}; use crate::repository::error::RepositoryError; -use crate::repository::traits::WebhookRepository; +use crate::repository::traits::{NewWebhookDelivery, WebhookRepository}; const WEBHOOK_TIMEOUT_SECS: u64 = 30; const MAX_RESPONSE_BODY_CHARS: usize = 1024; @@ -326,15 +326,15 @@ impl WebhookService { ); self.webhook_repo - .create_delivery( - &delivery_id, - id, + .create_delivery(NewWebhookDelivery { + id: &delivery_id, + webhook_id: id, status, - Some(status_code), - Some(&truncated_body), - Some(duration_ms), + status_code: Some(status_code), + response_body: Some(&truncated_body), + duration_ms: Some(duration_ms), triggered_by, - ) + }) .await .map_err(|e| { error!( @@ -353,15 +353,15 @@ impl WebhookService { ); self.webhook_repo - .create_delivery( - &delivery_id, - id, - "failed", - None, - Some(&e.to_string()), - Some(duration_ms), + .create_delivery(NewWebhookDelivery { + id: &delivery_id, + webhook_id: id, + status: "failed", + status_code: None, + response_body: Some(&e.to_string()), + duration_ms: Some(duration_ms), triggered_by, - ) + }) .await .map_err(|e| { error!( diff --git a/apps/backend/src/storage/mod.rs b/apps/backend/src/storage/mod.rs index 021daeaf..ec225bff 100644 --- a/apps/backend/src/storage/mod.rs +++ b/apps/backend/src/storage/mod.rs @@ -20,7 +20,7 @@ pub enum StorageKind { } impl StorageKind { - pub fn from_str(s: &str) -> Option { + pub fn parse(s: &str) -> Option { match s { STORAGE_KIND_FILESYSTEM => Some(StorageKind::Filesystem), STORAGE_KIND_S3 => Some(StorageKind::S3), @@ -150,9 +150,9 @@ mod tests { #[test] fn test_storage_kind_from_str() { - assert_eq!(StorageKind::from_str("filesystem"), Some(StorageKind::Filesystem)); - assert_eq!(StorageKind::from_str("s3"), Some(StorageKind::S3)); - assert_eq!(StorageKind::from_str("unknown"), None); + assert_eq!(StorageKind::parse("filesystem"), Some(StorageKind::Filesystem)); + assert_eq!(StorageKind::parse("s3"), Some(StorageKind::S3)); + assert_eq!(StorageKind::parse("unknown"), None); } #[test] diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index dcd4538b..a230808f 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -13,8 +13,8 @@ use crate::models::user::User; use crate::repository::error::RepositoryError; use crate::repository::traits::{ AccessTokenLookupRow, AccessTokenRepository, CollectionRepository, EntriesListResult, EntryRepository, - FileListResult, FileRepository, ListEntriesParams, ListFilesParams, RevisionsListResult, SessionRepository, - SiteRepository, UserRepository, + FileListResult, FileRepository, ListEntriesParams, ListFilesParams, NewAccessToken, NewFile, NewWebhookDelivery, + RevisionsListResult, SessionRepository, SiteRepository, UpdateEntryParams, UserRepository, }; pub fn now_timestamp() -> String { @@ -661,18 +661,18 @@ impl EntryRepository for InMemoryEntryRepository { Ok(entry) } - async fn update( - &self, - id: &str, - _site_id: &str, - data: &str, - slug: &str, - status: &str, - created_by: Option<&str>, - change_summary: Option<&str>, - ) -> Result { + async fn update(&self, params: UpdateEntryParams<'_>) -> Result { + let UpdateEntryParams { + id, + site_id, + data, + slug, + status, + created_by, + change_summary, + } = params; let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.iter_mut().find(|e| e.id == id) { + if let Some(entry) = entries.iter_mut().find(|e| e.id == id && e.site_id == site_id) { entry.data = data.to_string(); entry.slug = slug.to_string(); entry.status = status.to_string(); @@ -962,21 +962,21 @@ impl FileRepository for InMemoryFileRepository { }) } - async fn create( - &self, - id: &str, - site_id: &str, - filename: &str, - original_name: &str, - mime_type: &str, - size: i64, - storage_provider: &str, - storage_key: &str, - thumbnail_key: Option<&str>, - width: Option, - height: Option, - created_by: Option<&str>, - ) -> Result { + async fn create(&self, file: NewFile<'_>) -> Result { + let NewFile { + id, + site_id, + filename, + original_name, + mime_type, + size, + storage_provider, + storage_key, + thumbnail_key, + width, + height, + created_by, + } = file; let mut files = self.files.lock().unwrap(); let file = File { id: id.to_string(), @@ -1118,17 +1118,17 @@ impl AccessTokenRepository for InMemoryAccessTokenRepository { Ok(tokens.iter().filter(|t| t.site_id == site_id).cloned().collect()) } - async fn create( - &self, - id: &str, - site_id: &str, - name: &str, - _token_hash: &str, - token_prefix: &str, - token_hmac: &str, - permission: &str, - created_by_user_id: Option<&str>, - ) -> Result<(), RepositoryError> { + async fn create(&self, token: NewAccessToken<'_>) -> Result<(), RepositoryError> { + let NewAccessToken { + id, + site_id, + name, + token_hash: _, + token_prefix, + token_hmac, + permission, + created_by_user_id, + } = token; let mut tokens = self.tokens.lock().unwrap(); let token = AccessToken { id: id.to_string(), @@ -1277,14 +1277,17 @@ impl crate::repository::traits::WebhookRepository for InMemoryWebhookRepository async fn create_delivery( &self, - id: &str, - webhook_id: &str, - status: &str, - status_code: Option, - response_body: Option<&str>, - duration_ms: Option, - triggered_by: Option<&str>, + delivery: NewWebhookDelivery<'_>, ) -> Result { + let NewWebhookDelivery { + id, + webhook_id, + status, + status_code, + response_body, + duration_ms, + triggered_by, + } = delivery; let mut deliveries = self.deliveries.lock().unwrap(); let delivery = crate::models::webhook::WebhookDelivery { id: id.to_string(), diff --git a/apps/backend/tests/common/grpc.rs b/apps/backend/tests/common/grpc.rs index ac8e65b5..7df1239e 100644 --- a/apps/backend/tests/common/grpc.rs +++ b/apps/backend/tests/common/grpc.rs @@ -41,23 +41,25 @@ impl GrpcTestContext { let storage_dir = tempfile::tempdir().expect("Failed to create temp storage dir"); let storage_path = storage_dir.path().to_str().unwrap().to_string(); - let mut config = Config::default(); - config.database_url = "sqlite::memory:".to_string(); - config.hmac_secret = "test-hmac-secret-integration".to_string(); - config.storage_fs_path = Some(storage_path.clone()); - config.cookie_secure = false; - config.mcp_enabled = false; - config.rate_limit_max_requests = 10000; - config.rate_limit_window_secs = 60; - config.db_max_connections = 5; - config.db_min_connections = 1; - config.db_acquire_timeout_secs = 30; - config.db_idle_timeout_secs = 600; - config.max_upload_size_bytes = 50 * 1024 * 1024; - config.public_registration_enabled = true; - config.bcrypt_cost = bcrypt::DEFAULT_COST; - config.webhook_allow_private_targets = true; - config.backup_local_path = Some(format!("{storage_path}/backups")); + let config = Config { + database_url: "sqlite::memory:".to_string(), + hmac_secret: "test-hmac-secret-integration".to_string(), + storage_fs_path: Some(storage_path.clone()), + cookie_secure: false, + mcp_enabled: false, + rate_limit_max_requests: 10000, + rate_limit_window_secs: 60, + db_max_connections: 5, + db_min_connections: 1, + db_acquire_timeout_secs: 30, + db_idle_timeout_secs: 600, + max_upload_size_bytes: 50 * 1024 * 1024, + public_registration_enabled: true, + bcrypt_cost: bcrypt::DEFAULT_COST, + webhook_allow_private_targets: true, + backup_local_path: Some(format!("{storage_path}/backups")), + ..Default::default() + }; let pool = init_db_with_config(&config) .await diff --git a/apps/backend/tests/common/server.rs b/apps/backend/tests/common/server.rs index b89b88f6..f47851db 100644 --- a/apps/backend/tests/common/server.rs +++ b/apps/backend/tests/common/server.rs @@ -49,41 +49,42 @@ impl TestServer { // `TEST_DATABASE` selects one. The handle drops the database on teardown. let (database_url, db_handle) = super::test_db::provision().await; - let mut config = Config::default(); - config.database_url = database_url; - config.hmac_secret = "test-hmac-secret-integration".to_string(); - config.storage_fs_path = Some(storage_path.clone()); - config.cookie_secure = false; - // Real config defaults this to 24h; `Config::default()` leaves it 0, which - // mints already-expired sessions. SQLite hid that (it compares the datetime - // columns lexicographically, where the stored `…T…+00:00` sorts above - // `datetime('now')`); Postgres/MySQL do a real timestamp compare and reject. - config.session_lifetime_hours = 24; - config.mcp_enabled = true; - config.mcp_allowed_hosts = vec!["127.0.0.1".to_string()]; - config.mcp_allowed_origins = vec![]; - config.rate_limit_max_requests = 10000; - config.rate_limit_window_secs = 60; - // Tests spin up many servers in parallel against one shared Postgres/MySQL - // instance. Keep each pool tiny and release idle connections fast so the - // aggregate stays well under the server's connection ceiling (Postgres - // defaults to 100); otherwise high-core CI runners intermittently exhaust - // it. A single TestServer needs almost no internal concurrency. - config.db_max_connections = 2; - config.db_min_connections = 1; - config.db_acquire_timeout_secs = 30; - config.db_idle_timeout_secs = 5; - config.max_upload_size_bytes = 50 * 1024 * 1024; - config.public_registration_enabled = true; - config.bcrypt_cost = bcrypt::DEFAULT_COST; - config.webhook_allow_private_targets = true; - config.backup_local_path = Some(storage_dir.path().join("backups").to_string_lossy().into_owned()); - // Deterministic 32-byte (hex) key so encrypted-backup tests can round-trip. - config.backup_encryption_key = - Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff".to_string()); - config.backup_enabled = false; // don't run the poller during tests - config.search_enabled = search_enabled; - config.search_index_path = Some(storage_dir.path().join("search").to_string_lossy().into_owned()); + let config = Config { + database_url, + hmac_secret: "test-hmac-secret-integration".to_string(), + storage_fs_path: Some(storage_path.clone()), + cookie_secure: false, + // Real config defaults this to 24h; `Config::default()` leaves it 0, which + // mints already-expired sessions. SQLite hid that (it compares the datetime + // columns lexicographically, where the stored `…T…+00:00` sorts above + // `datetime('now')`); Postgres/MySQL do a real timestamp compare and reject. + session_lifetime_hours: 24, + mcp_enabled: true, + mcp_allowed_hosts: vec!["127.0.0.1".to_string()], + mcp_allowed_origins: vec![], + rate_limit_max_requests: 10000, + rate_limit_window_secs: 60, + // Tests spin up many servers in parallel against one shared Postgres/MySQL + // instance. Keep each pool tiny and release idle connections fast so the + // aggregate stays well under the server's connection ceiling (Postgres + // defaults to 100); otherwise high-core CI runners intermittently exhaust + // it. A single TestServer needs almost no internal concurrency. + db_max_connections: 2, + db_min_connections: 1, + db_acquire_timeout_secs: 30, + db_idle_timeout_secs: 5, + max_upload_size_bytes: 50 * 1024 * 1024, + public_registration_enabled: true, + bcrypt_cost: bcrypt::DEFAULT_COST, + webhook_allow_private_targets: true, + backup_local_path: Some(storage_dir.path().join("backups").to_string_lossy().into_owned()), + // Deterministic 32-byte (hex) key so encrypted-backup tests can round-trip. + backup_encryption_key: Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff".to_string()), + backup_enabled: false, // don't run the poller during tests + search_enabled, + search_index_path: Some(storage_dir.path().join("search").to_string_lossy().into_owned()), + ..Default::default() + }; let pool = init_db_with_config(&config) .await diff --git a/apps/backend/tests/graphql/collections_tests.rs b/apps/backend/tests/graphql/collections_tests.rs index 01d542a8..0caeedb4 100644 --- a/apps/backend/tests/graphql/collections_tests.rs +++ b/apps/backend/tests/graphql/collections_tests.rs @@ -165,7 +165,7 @@ async fn test_delete_collection_mutation() { let body = gql(&server, &token, r#"mutation { deleteCollection(slug: "to-delete") }"#).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["deleteCollection"].as_bool().unwrap(), true); + assert!(body["data"]["deleteCollection"].as_bool().unwrap()); } #[tokio::test] @@ -177,7 +177,7 @@ async fn test_create_singleton_collection() { assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); assert_eq!(body["data"]["createCollection"]["name"].as_str().unwrap(), "Settings"); assert_eq!(body["data"]["createCollection"]["slug"].as_str().unwrap(), "settings"); - assert_eq!(body["data"]["createCollection"]["isSingleton"].as_bool().unwrap(), true); + assert!(body["data"]["createCollection"]["isSingleton"].as_bool().unwrap()); } #[tokio::test] @@ -225,5 +225,5 @@ async fn test_collection_not_singleton() { let body = gql(&server, &token, r#"{ collection(slug: "regular") { id isSingleton } }"#).await; assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - assert_eq!(body["data"]["collection"]["isSingleton"].as_bool().unwrap(), false); + assert!(!body["data"]["collection"]["isSingleton"].as_bool().unwrap()); } diff --git a/apps/backend/tests/graphql/entries_tests.rs b/apps/backend/tests/graphql/entries_tests.rs index 95691339..a976c691 100644 --- a/apps/backend/tests/graphql/entries_tests.rs +++ b/apps/backend/tests/graphql/entries_tests.rs @@ -286,7 +286,7 @@ async fn test_delete_entry_mutation() { let query = format!(r#"mutation {{ deleteEntry(id: "{}") }}"#, entry_id); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["deleteEntry"].as_bool().unwrap(), true); + assert!(body["data"]["deleteEntry"].as_bool().unwrap()); } #[tokio::test] diff --git a/apps/backend/tests/graphql/files_tests.rs b/apps/backend/tests/graphql/files_tests.rs index 96697a28..7b09b700 100644 --- a/apps/backend/tests/graphql/files_tests.rs +++ b/apps/backend/tests/graphql/files_tests.rs @@ -170,7 +170,7 @@ async fn test_delete_file_mutation() { let query = format!(r#"mutation {{ deleteFile(id: "{}") }}"#, file_id); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["deleteFile"].as_bool().unwrap(), true); + assert!(body["data"]["deleteFile"].as_bool().unwrap()); } #[tokio::test] @@ -195,7 +195,7 @@ async fn test_restore_file_mutation() { let query = format!(r#"mutation {{ restoreFile(id: "{}") }}"#, file_id); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["restoreFile"].as_bool().unwrap(), true); + assert!(body["data"]["restoreFile"].as_bool().unwrap()); } #[tokio::test] diff --git a/apps/backend/tests/graphql/webhooks_tests.rs b/apps/backend/tests/graphql/webhooks_tests.rs index a7951cb0..0a80a89d 100644 --- a/apps/backend/tests/graphql/webhooks_tests.rs +++ b/apps/backend/tests/graphql/webhooks_tests.rs @@ -162,7 +162,7 @@ async fn test_delete_webhook_mutation() { ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["deleteWebhook"].as_bool().unwrap(), true); + assert!(body["data"]["deleteWebhook"].as_bool().unwrap()); } #[tokio::test] diff --git a/apps/backend/tests/grpc/files_tests.rs b/apps/backend/tests/grpc/files_tests.rs index 78a82322..01b3967b 100644 --- a/apps/backend/tests/grpc/files_tests.rs +++ b/apps/backend/tests/grpc/files_tests.rs @@ -20,7 +20,7 @@ async fn seed_file(ctx: &GrpcTestContext, site_id: &str, name: &str) -> String { } async fn seed_file_with_mime(ctx: &GrpcTestContext, site_id: &str, name: &str, mime_type: &str) -> String { - let ext = mime_type.split('/').last().unwrap_or("bin"); + let ext = mime_type.split('/').next_back().unwrap_or("bin"); let body = ctx .upload_file(site_id, &format!("{}.{}", name, ext), b"test content", mime_type) .await; diff --git a/apps/backend/tests/grpc/webhooks_tests.rs b/apps/backend/tests/grpc/webhooks_tests.rs index 33ce9bce..a2ca0072 100644 --- a/apps/backend/tests/grpc/webhooks_tests.rs +++ b/apps/backend/tests/grpc/webhooks_tests.rs @@ -34,7 +34,7 @@ async fn test_create_webhook() { assert_eq!(resp.label, "Test Webhook"); assert_eq!(resp.url, "https://example.com/hook"); assert_eq!(resp.site_id, site_id); - assert!(resp.headers.get("X-Custom").is_some()); + assert!(resp.headers.contains_key("X-Custom")); assert!(!resp.id.is_empty()); } @@ -139,7 +139,7 @@ async fn test_update_webhook() { assert_eq!(updated.label, "New Hook"); assert_eq!(updated.url, "https://example.com/new"); - assert!(updated.headers.get("X-Updated").is_some()); + assert!(updated.headers.contains_key("X-Updated")); } #[tokio::test] @@ -262,6 +262,6 @@ async fn test_list_webhook_deliveries() { .unwrap() .into_inner(); - assert!(resp.items.len() >= 1); + assert!(!resp.items.is_empty()); assert!(resp.total >= 1); } diff --git a/apps/backend/tests/mcp_stdio.rs b/apps/backend/tests/mcp_stdio.rs index cb1eee4a..41178493 100644 --- a/apps/backend/tests/mcp_stdio.rs +++ b/apps/backend/tests/mcp_stdio.rs @@ -148,11 +148,8 @@ async fn setup_database(permission: AccessTokenPermission) -> (tempfile::TempDir /// HMAC secret. Mirrors what `cms serve` leaves behind on first run. async fn setup_home_instance(home: &std::path::Path) -> String { let hmac_secret = "home-instance-hmac-secret".to_string(); - std::fs::write( - home.join("secrets.toml"), - format!("hmac_secret = \"{hmac_secret}\"\n"), - ) - .expect("write secrets.toml"); + std::fs::write(home.join("secrets.toml"), format!("hmac_secret = \"{hmac_secret}\"\n")) + .expect("write secrets.toml"); let database_path = home.join("cms.db"); let database_url = format!("sqlite://{}", database_path.to_string_lossy().replace('\\', "/")); diff --git a/apps/backend/tests/rest/backups_tests.rs b/apps/backend/tests/rest/backups_tests.rs index a04b20b2..2627b8b8 100644 --- a/apps/backend/tests/rest/backups_tests.rs +++ b/apps/backend/tests/rest/backups_tests.rs @@ -514,5 +514,8 @@ async fn instance_backup_and_restore_round_trip() { // Instance restore wipes sessions; re-login, then verify the data is back. let (token2, csrf2) = login(&server, "admin", "admin").await; - assert_eq!(get_entry_status(&server, &token2, &csrf2, &site_id, &entry_id).await, 200); + assert_eq!( + get_entry_status(&server, &token2, &csrf2, &site_id, &entry_id).await, + 200 + ); } diff --git a/apps/backend/tests/rest/collections_tests.rs b/apps/backend/tests/rest/collections_tests.rs index d44c7ed5..aa3500d3 100644 --- a/apps/backend/tests/rest/collections_tests.rs +++ b/apps/backend/tests/rest/collections_tests.rs @@ -2,7 +2,14 @@ use serde_json::{Value, json}; use crate::common::{TestServer, auth::auth_header, fixtures::setup}; -async fn create_collection(server: &TestServer, token: &str, csrf: &str, site_id: &str, name: &str, slug: &str) -> Value { +async fn create_collection( + server: &TestServer, + token: &str, + csrf: &str, + site_id: &str, + name: &str, + slug: &str, +) -> Value { let client = reqwest::Client::builder().build().unwrap(); let resp = client .post(format!( diff --git a/apps/backend/tests/rest/roles_tests.rs b/apps/backend/tests/rest/roles_tests.rs index 0ecac1ad..74e91e30 100644 --- a/apps/backend/tests/rest/roles_tests.rs +++ b/apps/backend/tests/rest/roles_tests.rs @@ -119,7 +119,13 @@ async fn remove_member( .unwrap() } -async fn create_collection(server: &TestServer, token: &str, csrf: &str, site_id: &str, slug: &str) -> reqwest::Response { +async fn create_collection( + server: &TestServer, + token: &str, + csrf: &str, + site_id: &str, + slug: &str, +) -> reqwest::Response { let client = reqwest::Client::builder().build().unwrap(); client .post(format!( diff --git a/apps/dashboard/src/components/app-breadcrumb.tsx b/apps/dashboard/src/components/app-breadcrumb.tsx index 2ad8821c..4977fcd5 100644 --- a/apps/dashboard/src/components/app-breadcrumb.tsx +++ b/apps/dashboard/src/components/app-breadcrumb.tsx @@ -1,6 +1,6 @@ -import { Fragment } from "react"; -import { useMatches, useParams, Link } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; +import { Link, useMatches, useParams } from "@tanstack/react-router"; +import { Fragment } from "react"; import { Breadcrumb, BreadcrumbItem, @@ -27,11 +27,19 @@ interface BreadcrumbConfig { const breadcrumbConfigs: BreadcrumbConfig[] = [ { routeId: "/_admin/sites/$siteId/entries/$collectionSlug/$id/edit", - crumbs: [{ labelFrom: "site" }, { labelFrom: "collection" }, { labelFrom: "entrySlug" }], + crumbs: [ + { labelFrom: "site" }, + { labelFrom: "collection" }, + { labelFrom: "entrySlug" }, + ], }, { routeId: "/_admin/sites/$siteId/entries/$collectionSlug/new", - crumbs: [{ labelFrom: "site" }, { labelFrom: "collection" }, { label: "New" }], + crumbs: [ + { labelFrom: "site" }, + { labelFrom: "collection" }, + { label: "New" }, + ], }, { routeId: "/_admin/sites/$siteId/entries/$collectionSlug/", @@ -70,44 +78,60 @@ function useSiteName(siteId: string | undefined) { const { data: site } = useQuery({ queryKey: ["site", siteId], - queryFn: () => getSite(siteId!), + queryFn: () => getSite(siteId as string), enabled: !!siteId && !siteFromList, }); return siteFromList?.name ?? site?.name; } -function useCollectionName(siteId: string | undefined, collectionSlug: string | undefined) { +function useCollectionName( + siteId: string | undefined, + collectionSlug: string | undefined, +) { const { data: collections } = useQuery({ queryKey: ["collections", siteId], - queryFn: () => getCollections(siteId!), + queryFn: () => getCollections(siteId as string), enabled: !!siteId && !!collectionSlug, }); - return collections?.find((c) => c.slug === collectionSlug)?.name ?? collectionSlug; + return ( + collections?.find((c) => c.slug === collectionSlug)?.name ?? collectionSlug + ); } -function useSingletonName(siteId: string | undefined, slug: string | undefined) { +function useSingletonName( + siteId: string | undefined, + slug: string | undefined, +) { const { data: collections } = useQuery({ queryKey: ["collections", siteId], - queryFn: () => getCollections(siteId!), + queryFn: () => getCollections(siteId as string), enabled: !!siteId && !!slug, }); - return collections?.find((c) => c.is_singleton && c.slug === slug)?.name ?? slug; + return ( + collections?.find((c) => c.is_singleton && c.slug === slug)?.name ?? slug + ); } -function useEntrySlugLabel(siteId: string | undefined, entryId: string | undefined) { +function useEntrySlugLabel( + siteId: string | undefined, + entryId: string | undefined, +) { const { data: entry } = useQuery({ queryKey: ["entry", siteId, entryId], - queryFn: () => getEntryById(siteId!, entryId!), + queryFn: () => getEntryById(siteId as string, entryId as string), enabled: !!siteId && !!entryId, }); return entry?.slug ?? entryId?.slice(0, 8); } -function useBreadcrumbLabels(defs: BreadcrumbDef[], params: Record) { +function useBreadcrumbLabels( + defs: BreadcrumbDef[], + params: Record, +) { const siteId = params.siteId; const siteName = useSiteName(siteId); const collectionName = useCollectionName(siteId, params.collectionSlug); @@ -125,11 +149,17 @@ function useBreadcrumbLabels(defs: BreadcrumbDef[], params: Record, crumbIndex: number): string | undefined { +function buildHref( + routeId: string, + params: Record, + crumbIndex: number, +): string | undefined { const siteId = params.siteId; if (!siteId) return undefined; @@ -155,7 +185,9 @@ export function AppBreadcrumb() { let config: BreadcrumbConfig | undefined; let routeId: string | undefined; for (let i = matches.length - 1; i >= 0; i--) { - const found = breadcrumbConfigs.find((c) => c.routeId === matches[i].routeId); + const found = breadcrumbConfigs.find( + (c) => c.routeId === matches[i].routeId, + ); if (found) { config = found; routeId = matches[i].routeId; @@ -186,8 +218,10 @@ export function AppBreadcrumb() { return ( - {config.crumbs.map((_, i) => ( - + {config.crumbs.map((def, i) => ( + {i > 0 && } @@ -202,18 +236,23 @@ export function AppBreadcrumb() { return ( - {labels.map((label, i) => { - const isLast = i === labels.length - 1; + {config.crumbs.map((def, i) => { + const label = labels[i]; + const isLast = i === config.crumbs.length - 1; const href = buildHref(routeId, params, i); return ( - + {i > 0 && } {isLast ? ( {label} ) : href ? ( - }>{label} + }> + {label} + ) : ( {label} )} @@ -224,4 +263,4 @@ export function AppBreadcrumb() { ); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/components/backups/backups-section.tsx b/apps/dashboard/src/components/backups/backups-section.tsx index f5cdd38a..86a406f4 100644 --- a/apps/dashboard/src/components/backups/backups-section.tsx +++ b/apps/dashboard/src/components/backups/backups-section.tsx @@ -29,6 +29,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -90,13 +91,17 @@ function formatBytes(bytes: number): string { return `${value.toFixed(1)} ${units[i]}`; } -function statusVariant(status: string): "default" | "secondary" | "destructive" { +function statusVariant( + status: string, +): "default" | "secondary" | "destructive" { if (status === "success") return "default"; if (status === "failed") return "destructive"; return "secondary"; } -type RestoreSource = { type: "backup"; backup: BackupInfo } | { type: "upload"; file: File }; +type RestoreSource = + | { type: "backup"; backup: BackupInfo } + | { type: "upload"; file: File }; export function BackupsSection({ scope }: { scope: BackupScope }) { const queryClient = useQueryClient(); @@ -106,8 +111,12 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { const [includeFiles, setIncludeFiles] = useState(true); const [encrypt, setEncrypt] = useState(false); - const [restoreSource, setRestoreSource] = useState(null); - const [restoreMode, setRestoreMode] = useState<"instance" | "site">(isInstance ? "instance" : "site"); + const [restoreSource, setRestoreSource] = useState( + null, + ); + const [restoreMode, setRestoreMode] = useState<"instance" | "site">( + isInstance ? "instance" : "site", + ); const [selectedSiteIds, setSelectedSiteIds] = useState([]); const [importAsNew, setImportAsNew] = useState(false); const [confirmText, setConfirmText] = useState(""); @@ -131,7 +140,8 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { queryClient.invalidateQueries({ queryKey: ["backup-schedules", scopeKey] }); const createMutation = useMutation({ - mutationFn: () => createBackup(scope, { include_files: includeFiles, encrypt }), + mutationFn: () => + createBackup(scope, { include_files: includeFiles, encrypt }), onSuccess: () => { invalidateBackups(); toast.success("Backup created"); @@ -153,11 +163,18 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { if (!restoreSource) return; // Site-settings scope: always restores into the current site (id from URL). if (!isInstance) { - const opts = { mode: "site" as const, import_as_new: importAsNew, confirm: RESTORE_WORD }; + const opts = { + mode: "site" as const, + import_as_new: importAsNew, + confirm: RESTORE_WORD, + }; if (restoreSource.type === "upload") { await restoreBackupUpload(scope, restoreSource.file, opts); } else { - await restoreBackup(scope, { backup_id: restoreSource.backup.id, ...opts }); + await restoreBackup(scope, { + backup_id: restoreSource.backup.id, + ...opts, + }); } return; } @@ -222,13 +239,16 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { } function toggleSite(id: string) { - setSelectedSiteIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id])); + setSelectedSiteIds((prev) => + prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id], + ); } const backups = backupsQuery.data ?? []; const schedules = schedulesQuery.data ?? []; // For an instance backup picking sites, at least one site must be selected. - const needsSitePick = isInstance && inspect?.scope === "instance" && restoreMode === "site"; + const needsSitePick = + isInstance && inspect?.scope === "instance" && restoreMode === "site"; const confirmReady = confirmText === RESTORE_WORD && !inspecting && @@ -253,17 +273,24 @@ export function BackupsSection({ scope }: { scope: BackupScope }) {
- - + + Include uploaded files + + + + setEncrypt(Boolean(v))} + /> + Encrypt +
- @@ -300,7 +330,9 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { Created Status Size - Files + + Files + Actions @@ -312,11 +344,17 @@ export function BackupsSection({ scope }: { scope: BackupScope }) {
- {b.status} - {b.encrypted && } + + {b.status} + + {b.encrypted && ( + + )}
- {formatBytes(b.size_bytes)} + + {formatBytes(b.size_bytes)} + {b.includes_files ? b.file_count : "—"} @@ -328,15 +366,19 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { variant="ghost" size="icon" title="Download" - render={} - > - - + render={ + + + + } + /> @@ -391,14 +433,18 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { /> {/* Restore confirmation */} - !open && closeRestore()}> + !open && closeRestore()} + > Confirm restore - Restoring replaces all data within the chosen scope. This cannot be undone. + Restoring replaces all data within the chosen scope. This cannot + be undone. @@ -409,7 +455,9 @@ export function BackupsSection({ scope }: { scope: BackupScope }) {
)} {isInstance && inspectError && ( -

Could not read this backup: {inspectError}

+

+ Could not read this backup: {inspectError} +

)} {/* A single-site backup: no picker, just name the site being restored. */} @@ -417,7 +465,9 @@ export function BackupsSection({ scope }: { scope: BackupScope }) {

Restores the site{" "} - {inspect.sites[0]?.name ?? inspect.sites[0]?.id ?? "in this backup"} + {inspect.sites[0]?.name ?? + inspect.sites[0]?.id ?? + "in this backup"} .

@@ -427,7 +477,12 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { {isInstance && inspect?.scope === "instance" && (
- + setRestoreMode(v as "instance" | "site") + } + > @@ -441,22 +496,34 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { sites={inspect.sites} selected={selectedSiteIds} onToggle={toggleSite} - onToggleAll={(all) => setSelectedSiteIds(all ? inspect.sites.map((s) => s.id) : [])} + onToggleAll={(all) => + setSelectedSiteIds( + all ? inspect.sites.map((s) => s.id) : [], + ) + } /> )}
)} {((isInstance && restoreMode === "site") || !isInstance) && ( - + + setImportAsNew(Boolean(v))} + /> + + Import as a new site (keep the existing one) + + )}
void; }) { if (sites.length === 0) { - return

This backup contains no sites.

; + return ( +

+ This backup contains no sites. +

+ ); } const allSelected = selected.length === sites.length; return (
- + + onToggleAll(Boolean(v))} + /> + + Select all ({selected.length}/{sites.length}) + +
{sites.map((s) => ( - + onToggle(s.id)} + /> + + + + {s.name ?? "(unnamed site)"} + + + {s.id} + + + + ))}
@@ -536,7 +627,14 @@ interface SchedulesCardProps { onDelete: (id: string) => Promise; } -function SchedulesCard({ schedules, loading, onCreate, onToggle, onRun, onDelete }: SchedulesCardProps) { +function SchedulesCard({ + schedules, + loading, + onCreate, + onToggle, + onRun, + onDelete, +}: SchedulesCardProps) { const [preset, setPreset] = useState(CRON_PRESETS[0].value); const [customCron, setCustomCron] = useState("0 2 * * *"); const [retention, setRetention] = useState(7); @@ -608,14 +706,22 @@ function SchedulesCard({ schedules, loading, onCreate, onToggle, onRun, onDelete />
- - + + setIncludeFiles(Boolean(v))} + /> + Files + + + setEncrypt(Boolean(v))} + /> + Encrypt +
- -
diff --git a/apps/dashboard/src/components/dashboard-header.tsx b/apps/dashboard/src/components/dashboard-header.tsx index a318d063..35c6f670 100644 --- a/apps/dashboard/src/components/dashboard-header.tsx +++ b/apps/dashboard/src/components/dashboard-header.tsx @@ -54,7 +54,11 @@ export function DashboardHeader() { +
); diff --git a/apps/dashboard/src/components/file-picker-dialog.tsx b/apps/dashboard/src/components/file-picker-dialog.tsx index 0c02b48e..76bc7879 100644 --- a/apps/dashboard/src/components/file-picker-dialog.tsx +++ b/apps/dashboard/src/components/file-picker-dialog.tsx @@ -5,22 +5,12 @@ import { ImagePlus, Music, Search, - Trash2, Upload, Video, } from "lucide-react"; import { useCallback, useMemo, useRef, 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 { @@ -32,16 +22,10 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - deleteFile, - type FileItem, - type FileReference, - getFileReferences, - getFiles, - uploadFile, -} from "@/lib/api"; +import { type FileItem, getFiles, uploadFile } from "@/lib/api"; // --------------------------------------------------------------------------- // Types @@ -139,16 +123,16 @@ export function FilePickerDialog({ const [page, setPage] = useState(1); const [tab, setTab] = useState("library"); const [dragOver, setDragOver] = useState(false); - const [pendingDelete, setPendingDelete] = useState<{ - file: FileItem; - refs: FileReference[]; - } | null>(null); const fileInputRef = useRef(null); const queryClient = useQueryClient(); // Derived values – memoised so they don't recalculate on every render. const fileType = useMemo(() => deriveFileType(accept), [accept]); + const skeletonKeys = useMemo( + () => Array.from({ length: 8 }, (_, i) => i), + [], + ); const { data, isLoading, isError } = useQuery({ queryKey: ["files", siteId, page, search, fileType], @@ -194,16 +178,6 @@ export function FilePickerDialog({ onError: (err: Error) => toast.error(err.message), }); - const deleteMutation = useMutation({ - mutationFn: (fileId: string) => deleteFile(siteId, fileId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["files", siteId] }); - toast.success("File deleted"); - setPendingDelete(null); - }, - onError: (err: Error) => toast.error(err.message), - }); - // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -226,33 +200,6 @@ export function FilePickerDialog({ [handleFileSelect], ); - const handleDelete = useCallback( - async (file: FileItem) => { - let refs: FileReference[] = []; - try { - refs = await getFileReferences(siteId, file.id); - } catch { - // Treat a failed reference lookup the same as "no references" so the - // user can still delete the file, but warn them just in case. - toast.warning( - "Could not check file references. Proceeding with delete.", - ); - } - - if (refs.length > 0) { - setPendingDelete({ file, refs }); - } else { - deleteMutation.mutate(file.id); - } - }, - [siteId, deleteMutation], - ); - - const confirmDelete = useCallback(() => { - if (!pendingDelete) return; - deleteMutation.mutate(pendingDelete.file.id); - }, [pendingDelete, deleteMutation]); - const handleSearchChange = useCallback( (e: React.ChangeEvent) => { setSearch(e.target.value); @@ -293,64 +240,60 @@ export function FilePickerDialog({ ); - const pendingCollections = pendingDelete - ? [...new Set(pendingDelete.refs.map((r) => r.collection_name))].join(", ") - : ""; - return ( - <> - - - - File Library - - Select an existing file or upload a new one. - - - - - - Library - Upload - + + + + File Library + + Select an existing file or upload a new one. + + + + + + Library + Upload + - {/* ---------------------------------------------------------------- + {/* ---------------------------------------------------------------- Library tab ---------------------------------------------------------------- */} - -
- - + +
+ + +
+ + {isLoading ? ( +
+ {skeletonKeys.map((k) => ( + + ))}
- - {isLoading ? ( -
- {Array.from({ length: 8 }, (_, i) => ( - - ))} -
- ) : isError ? ( -
- Failed to load files. Please try again. -
- ) : filteredItems.length === 0 ? ( -
- No files found. -
- ) : ( -
+ ) : isError ? ( +
+ Failed to load files. Please try again. +
+ ) : filteredItems.length === 0 ? ( +
+ No files found. +
+ ) : ( + +
{filteredItems.map((file) => ( handleDelete(file)} /> ))}
- )} - - {data && data.total > data.per_page && ( -
- - {filteredItems.length} of {data.total} files - -
- - -
+ + )} + + {data && data.total > data.per_page && ( +
+ + {filteredItems.length} of {data.total} files + +
+ +
- )} - +
+ )} + - {/* ---------------------------------------------------------------- + {/* ---------------------------------------------------------------- Upload tab — using a
- - {/* ----------------------------------------------------------------------- - Delete-with-references confirmation dialog - ----------------------------------------------------------------------- */} - { - if (!isOpen) setPendingDelete(null); - }} - > - - - Delete file? - - This file is used in {pendingDelete?.refs.length}{" "} - content item - {pendingDelete?.refs.length === 1 ? "" : "s"} - {pendingCollections ? ` (${pendingCollections})` : ""}. Deleting - it may break those pages. - - - - - Cancel - - - {deleteMutation.isPending ? "Deleting…" : "Delete"} - - - - - + {uploadIcon} + {uploadMutation.isPending ? "Uploading…" : "Choose File"} + + handleFileSelect(e.target.files)} + // Reset value so the same file can be re-uploaded if needed. + onClick={(e) => { + (e.target as HTMLInputElement).value = ""; + }} + /> + + +
+ + + + +
+
); } @@ -504,27 +411,23 @@ export function FilePickerDialog({ interface FileGridItemProps { file: FileItem; onSelect: () => void; - onDelete: () => void; } -function FileGridItem({ file, onSelect, onDelete }: FileGridItemProps) { +function FileGridItem({ file, onSelect }: FileGridItemProps) { const isImage = file.mime_type.startsWith("image/"); const isVideo = file.mime_type.startsWith("video/"); const hasPreview = isImage || (isVideo && !!file.thumbnail_url); return ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onSelect(); - } - }} - > +
+ {/* Overlay button captures card clicks */} + -
- {/* Filename / size overlay */}

{file.original_name}

diff --git a/apps/dashboard/src/components/revisions-panel.tsx b/apps/dashboard/src/components/revisions-panel.tsx index 0a657044..e59a49fe 100644 --- a/apps/dashboard/src/components/revisions-panel.tsx +++ b/apps/dashboard/src/components/revisions-panel.tsx @@ -1,5 +1,6 @@ -import { useCallback, useMemo, useState } from "react"; +import { useForm } from "@tanstack/react-form"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; import { AlertTriangle, ChevronDown, @@ -10,11 +11,10 @@ import { RotateCcw, User, } from "lucide-react"; -import { formatDistanceToNow } from "date-fns"; +import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { useForm } from "@tanstack/react-form"; import { z } from "zod"; - +import { DynamicForm } from "@/components/dynamic-form"; import { AlertDialog, AlertDialogAction, @@ -36,10 +36,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import { Skeleton } from "@/components/ui/skeleton"; import { Sheet, SheetContent, @@ -47,20 +45,21 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DynamicForm } from "@/components/dynamic-form"; import { - getEntryRevisions, - restoreEntryRevision, type Entry, type EntryRevision, + getEntryRevisions, + restoreEntryRevision, type SchemaDefinition, } from "@/lib/api"; +import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Constants @@ -390,10 +389,11 @@ export function RevisionsPanel({ // --------------------------------------------------------------------------- function RevisionListSkeleton() { + const keys = useMemo(() => Array.from({ length: 5 }, (_, i) => i), []); return (
- {Array.from({ length: 5 }, (_, i) => ( -
+ {keys.map((k) => ( +
diff --git a/apps/dashboard/src/components/sidebar/app-sidebar.tsx b/apps/dashboard/src/components/sidebar/app-sidebar.tsx index 28494993..907916d4 100644 --- a/apps/dashboard/src/components/sidebar/app-sidebar.tsx +++ b/apps/dashboard/src/components/sidebar/app-sidebar.tsx @@ -30,7 +30,7 @@ import { useAuth } from "@/contexts/auth-context"; import { getCollections, getSites, siteRoleLabel } from "@/lib/api"; export function AppSidebar({ ...props }: ComponentProps) { - const { siteId } = useParams({ from: "/_admin/sites/$siteId" as any }); + const { siteId } = useParams({ from: "/_admin/sites/$siteId" }); const auth = useAuth(); const pathname = useRouterState({ select: (s) => s.location.pathname }); diff --git a/apps/dashboard/src/components/sidebar/site-switcher.tsx b/apps/dashboard/src/components/sidebar/site-switcher.tsx index 2a2743cb..7f293078 100644 --- a/apps/dashboard/src/components/sidebar/site-switcher.tsx +++ b/apps/dashboard/src/components/sidebar/site-switcher.tsx @@ -37,7 +37,7 @@ export function SiteSwitcher({ }) { const { isMobile } = useSidebar(); const navigate = useNavigate(); - const { siteId } = useParams({ from: "/_admin/sites/$siteId" as any }); + const { siteId } = useParams({ from: "/_admin/sites/$siteId" }); const [sidebarHovered, setSidebarHovered] = useState(false); const [hoveredSiteId, setHoveredSiteId] = useState(null); @@ -120,7 +120,11 @@ export function SiteSwitcher({ className="gap-3 px-3 py-2.5" >
- +
{team.name} diff --git a/apps/dashboard/src/components/site-avatar.tsx b/apps/dashboard/src/components/site-avatar.tsx index 02c77904..e782665d 100644 --- a/apps/dashboard/src/components/site-avatar.tsx +++ b/apps/dashboard/src/components/site-avatar.tsx @@ -1,43 +1,43 @@ -import { Hashvatar } from "hashvatar/react"; -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; -import { cn } from "@/lib/utils"; -import { useState } from "react"; - -type SiteAvatarProps = { - siteName: string; - size?: number; - siteLogo?: string | null; - className?: string | undefined; - animate?: boolean; -}; - -export function SiteAvatar({ - siteName, - size = 32, - siteLogo, - className, - animate, -}: SiteAvatarProps) { - const [isHovered, setIsHovered] = useState(false); - const shouldAnimate = animate !== undefined ? animate : isHovered; - - return ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - - - - - ); -} +import { Hashvatar } from "hashvatar/react"; +import { useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +type SiteAvatarProps = { + siteName: string; + size?: number; + siteLogo?: string | null; + className?: string | undefined; + animate?: boolean; +}; + +export function SiteAvatar({ + siteName, + size = 32, + siteLogo, + className, + animate, +}: SiteAvatarProps) { + const [isHovered, setIsHovered] = useState(false); + const shouldAnimate = animate !== undefined ? animate : isHovered; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + + + ); +} diff --git a/apps/dashboard/src/components/site-settings/members-section.tsx b/apps/dashboard/src/components/site-settings/members-section.tsx index df25a162..2eb70123 100644 --- a/apps/dashboard/src/components/site-settings/members-section.tsx +++ b/apps/dashboard/src/components/site-settings/members-section.tsx @@ -97,7 +97,9 @@ export function MembersSection({ }); const handleAdd = () => { - const user = candidates.find((candidate) => candidate.id === selectedUserId); + const user = candidates.find( + (candidate) => candidate.id === selectedUserId, + ); if (user) inviteMutation.mutate(user.username); }; diff --git a/apps/dashboard/src/components/site-settings/user-combobox.tsx b/apps/dashboard/src/components/site-settings/user-combobox.tsx index 04680552..ae7358e5 100644 --- a/apps/dashboard/src/components/site-settings/user-combobox.tsx +++ b/apps/dashboard/src/components/site-settings/user-combobox.tsx @@ -44,19 +44,23 @@ export function UserCombobox({ render={ } /> - + diff --git a/apps/dashboard/src/components/tiptap-editor.tsx b/apps/dashboard/src/components/tiptap-editor.tsx index 47f6b68f..7abfe117 100644 --- a/apps/dashboard/src/components/tiptap-editor.tsx +++ b/apps/dashboard/src/components/tiptap-editor.tsx @@ -248,7 +248,12 @@ export function TiptapEditor({ > - diff --git a/apps/dashboard/src/contexts/auth-context.tsx b/apps/dashboard/src/contexts/auth-context.tsx index 7cddf374..c21482f7 100644 --- a/apps/dashboard/src/contexts/auth-context.tsx +++ b/apps/dashboard/src/contexts/auth-context.tsx @@ -1,6 +1,6 @@ -import { createContext, type ReactNode, useContext } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { logoutApi, getMe, type UserPublic } from "@/lib/api"; +import { createContext, type ReactNode, useContext } from "react"; +import { getMe, logoutApi, type UserPublic } from "@/lib/api"; interface AuthContextValue { user: UserPublic | null; diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 484957cc..017356c9 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -3,9 +3,13 @@ const AUTH_URL = "/api/auth"; export class ApiError extends Error { status: number; - body?: any; + body?: Record; - constructor(status: number, message?: string, body?: any) { + constructor( + status: number, + message?: string, + body?: Record, + ) { super(message); this.status = status; this.body = body; @@ -89,7 +93,9 @@ export function isOperator(role: InstanceRole | null | undefined): boolean { } /** Human label for an instance role, used in settings/user lists. */ -export function instanceRoleLabel(role: InstanceRole | null | undefined): string { +export function instanceRoleLabel( + role: InstanceRole | null | undefined, +): string { if (role === "instance_owner") return "Instance owner"; if (role === "instance_admin") return "Instance admin"; return "User"; @@ -597,7 +603,10 @@ export async function listBackups(scope: BackupScope) { return api(`${backupScopePrefix(scope)}/backups`); } -export async function createBackup(scope: BackupScope, input: CreateBackupInput) { +export async function createBackup( + scope: BackupScope, + input: CreateBackupInput, +) { return api(`${backupScopePrefix(scope)}/backups`, { method: "POST", body: JSON.stringify(input), @@ -611,7 +620,10 @@ export async function deleteBackup(scope: BackupScope, backupId: string) { } /** Same-origin URL for downloading a backup artifact (auth via cookie). */ -export function backupDownloadUrl(scope: BackupScope, backupId: string): string { +export function backupDownloadUrl( + scope: BackupScope, + backupId: string, +): string { return `${BASE_URL}${backupScopePrefix(scope)}/backups/${backupId}/download`; } @@ -625,7 +637,12 @@ export async function restoreBackup(scope: BackupScope, input: RestoreInput) { export async function restoreBackupUpload( scope: BackupScope, file: File, - opts: { mode?: "instance" | "site"; site_id?: string; import_as_new?: boolean; confirm: string }, + opts: { + mode?: "instance" | "site"; + site_id?: string; + import_as_new?: boolean; + confirm: string; + }, ) { const formData = new FormData(); formData.append("file", file); @@ -634,15 +651,22 @@ export async function restoreBackupUpload( formData.append("import_as_new", opts.import_as_new ? "true" : "false"); formData.append("confirm", opts.confirm); const csrfToken = getCsrfToken(); - const res = await fetch(`${BASE_URL}${backupScopePrefix(scope)}/restore/upload`, { - method: "POST", - credentials: "include", - body: formData, - headers: csrfToken ? { "X-CSRF-Token": csrfToken } : undefined, - }); + const res = await fetch( + `${BASE_URL}${backupScopePrefix(scope)}/restore/upload`, + { + method: "POST", + credentials: "include", + body: formData, + headers: csrfToken ? { "X-CSRF-Token": csrfToken } : undefined, + }, + ); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new ApiError(res.status, body.message || body.error || "Restore failed", body); + throw new ApiError( + res.status, + body.message || body.error || "Restore failed", + body, + ); } } @@ -666,15 +690,22 @@ export async function inspectBackupUpload(scope: BackupScope, file: File) { const formData = new FormData(); formData.append("file", file); const csrfToken = getCsrfToken(); - const res = await fetch(`${BASE_URL}${backupScopePrefix(scope)}/restore/inspect/upload`, { - method: "POST", - credentials: "include", - body: formData, - headers: csrfToken ? { "X-CSRF-Token": csrfToken } : undefined, - }); + const res = await fetch( + `${BASE_URL}${backupScopePrefix(scope)}/restore/inspect/upload`, + { + method: "POST", + credentials: "include", + body: formData, + headers: csrfToken ? { "X-CSRF-Token": csrfToken } : undefined, + }, + ); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new ApiError(res.status, body.message || body.error || "Could not read backup file", body); + throw new ApiError( + res.status, + body.message || body.error || "Could not read backup file", + body, + ); } return (await res.json()) as InspectResult; } @@ -683,14 +714,21 @@ export async function listBackupSchedules(scope: BackupScope) { return api(`${backupScopePrefix(scope)}/backup-schedules`); } -export async function createBackupSchedule(scope: BackupScope, input: ScheduleInput) { +export async function createBackupSchedule( + scope: BackupScope, + input: ScheduleInput, +) { return api(`${backupScopePrefix(scope)}/backup-schedules`, { method: "POST", body: JSON.stringify(input), }); } -export async function updateBackupSchedule(scope: BackupScope, id: string, input: ScheduleInput) { +export async function updateBackupSchedule( + scope: BackupScope, + id: string, + input: ScheduleInput, +) { return api(`${backupScopePrefix(scope)}/backup-schedules/${id}`, { method: "PUT", body: JSON.stringify(input), @@ -704,9 +742,12 @@ export async function deleteBackupSchedule(scope: BackupScope, id: string) { } export async function runBackupSchedule(scope: BackupScope, id: string) { - return api(`${backupScopePrefix(scope)}/backup-schedules/${id}/run`, { - method: "POST", - }); + return api( + `${backupScopePrefix(scope)}/backup-schedules/${id}/run`, + { + method: "POST", + }, + ); } // --- Sites API --- diff --git a/apps/dashboard/src/routes/_admin.tsx b/apps/dashboard/src/routes/_admin.tsx index 8a9631fd..4c02ad61 100644 --- a/apps/dashboard/src/routes/_admin.tsx +++ b/apps/dashboard/src/routes/_admin.tsx @@ -11,7 +11,7 @@ export const Route = createFileRoute("/_admin")({ if (user.must_change_password && location.pathname !== "/account") { throw redirect({ to: "/account" }); } - } catch (err: any) { + } catch (err: unknown) { // Only redirect on auth failure if (err instanceof ApiError && err.status === 401) { throw redirect({ to: "/login" }); diff --git a/apps/dashboard/src/routes/_admin/_shell/index.tsx b/apps/dashboard/src/routes/_admin/_shell/index.tsx index f5881c35..e2506d4d 100644 --- a/apps/dashboard/src/routes/_admin/_shell/index.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/index.tsx @@ -9,6 +9,7 @@ import { Cloud, Globe, HardDrive, Plus } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; +import { SiteAvatar } from "@/components/site-avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -45,10 +46,9 @@ import { createSite, getSites, isOperator, - siteRoleLabel, type SiteWithRole, + siteRoleLabel, } from "@/lib/api"; -import { SiteAvatar } from "@/components/site-avatar"; export const Route = createFileRoute("/_admin/_shell/")({ validateSearch: z.object({ diff --git a/apps/dashboard/src/routes/_admin/_shell/settings.tsx b/apps/dashboard/src/routes/_admin/_shell/settings.tsx index 14dfda88..dd269cc6 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings.tsx @@ -42,31 +42,31 @@ function InstanceSettingsLayout() {
- + + } + > + General + + } + > + Users + + {isOwner && ( } + render={} > - General + Backups - } - > - Users - - {isOwner && ( - } - > - Backups - - )} - + )} + diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId.tsx index ecb46e34..b02aaff1 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId.tsx @@ -1,13 +1,13 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { AppBreadcrumb } from "@/components/app-breadcrumb"; +import { AppSidebar } from "@/components/sidebar/app-sidebar"; +import { ModeToggle } from "@/components/theme-toggle"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar"; -import { ModeToggle } from "@/components/theme-toggle"; export const Route = createFileRoute("/_admin/sites/$siteId")({ component: SiteLayout, @@ -33,4 +33,4 @@ function SiteLayout() { ); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/collections.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/collections.tsx index 3f08f8ba..78e4aef7 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId/collections.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/collections.tsx @@ -480,7 +480,7 @@ function SortableFieldItem({
{(field.options ?? []).map((opt, optIdx) => ( {opt} @@ -491,10 +491,7 @@ function SortableFieldItem({ const newOpts = (field.options ?? []).filter( (_, i) => i !== optIdx, ); - form.setFieldValue( - `fields[${index}].options`, - newOpts, - ); + form.setFieldValue(`fields[${index}].options`, newOpts); }} > × diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx index 44b06595..0ca45444 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx @@ -4,8 +4,8 @@ import { Outlet, useRouterState, } from "@tanstack/react-router"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useSiteRole } from "@/components/site-settings/use-site-role"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; export const Route = createFileRoute("/_admin/sites/$siteId/settings")({ component: SettingsLayout, @@ -31,69 +31,66 @@ function SettingsLayout() {
- + + } + > + General + + + } + > + Members + + {canManage && ( + + } + > + API Keys + + )} + {canManage && ( } + render={ + + } > - General + Webhooks + )} + {canManage && ( } > - Members + Backups - {canManage && ( - - } - > - API Keys - - )} - {canManage && ( - - } - > - Webhooks - - )} - {canManage && ( - - } - > - Backups - - )} - + )} + diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index abcdb6fb..349e876b 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -117,7 +117,7 @@ --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); --font-heading: var(--font-sans); - --font-sans: 'Geist Variable', sans-serif; + --font-sans: "Geist Variable", sans-serif; --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); @@ -133,4 +133,4 @@ html { @apply font-sans; } -} \ No newline at end of file +} diff --git a/apps/web/biome.json b/apps/web/biome.json index 64ae1e3b..a2cf8bc3 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "$schema": "node_modules/@biomejs/biome/configuration_schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -13,7 +13,8 @@ "!.next", "!dist", "!build", - "!.source" + "!.source", + "!src/components/ui" ] }, "formatter": { @@ -37,5 +38,10 @@ "organizeImports": "on" } } + }, + "css": { + "parser": { + "tailwindDirectives": true + } } -} \ No newline at end of file +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 219f24de..ff8f13b0 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,11 +1,12 @@ -import { createMDX } from 'fumadocs-mdx/next'; +import { createMDX } from "fumadocs-mdx/next"; const withMDX = createMDX(); /** @type {import('next').NextConfig} */ const config = { - output: 'export', + output: "export", reactStrictMode: true, + images: { unoptimized: true }, }; export default withMDX(config); diff --git a/apps/web/package.json b/apps/web/package.json index ace557ee..f461a2c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,4 +40,4 @@ "typescript": "^6.0.3", "@biomejs/biome": "^2.4.16" } -} \ No newline at end of file +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index 297374d8..61e36849 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - '@tailwindcss/postcss': {}, + "@tailwindcss/postcss": {}, }, }; diff --git a/apps/web/project.json b/apps/web/project.json index eebf5a1a..d2bed1d0 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -1,45 +1,45 @@ -{ - "name": "web", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "apps/web/src", - "projectType": "application", - "targets": { - "build": { - "executor": "nx:run-commands", - "cache": true, - "options": { - "command": "bun run build", - "cwd": "apps/web" - }, - "outputs": ["{workspaceRoot}/apps/web/out"] - }, - "dev": { - "executor": "nx:run-commands", - "options": { - "command": "bun run dev", - "cwd": "apps/web" - } - }, - "typecheck": { - "executor": "nx:run-commands", - "options": { - "command": "bun run types:check", - "cwd": "apps/web" - } - }, - "lint": { - "executor": "nx:run-commands", - "options": { - "command": "bun run lint", - "cwd": "apps/web" - } - }, - "format": { - "executor": "nx:run-commands", - "options": { - "command": "bun run format", - "cwd": "apps/web" - } - } - } -} +{ + "name": "web", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/web/src", + "projectType": "application", + "targets": { + "build": { + "executor": "nx:run-commands", + "cache": true, + "options": { + "command": "bun run build", + "cwd": "apps/web" + }, + "outputs": ["{workspaceRoot}/apps/web/out"] + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "command": "bun run dev", + "cwd": "apps/web" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "bun run types:check", + "cwd": "apps/web" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "bun run lint", + "cwd": "apps/web" + } + }, + "format": { + "executor": "nx:run-commands", + "options": { + "command": "bun run format", + "cwd": "apps/web" + } + } + } +} diff --git a/apps/web/source.config.ts b/apps/web/source.config.ts index a35628ab..4cef0055 100644 --- a/apps/web/source.config.ts +++ b/apps/web/source.config.ts @@ -1,10 +1,10 @@ -import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; -import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; +import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; +import { defineConfig, defineDocs } from "fumadocs-mdx/config"; // You can customize Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections export const docs = defineDocs({ - dir: 'content/docs', + dir: "content/docs", docs: { schema: pageSchema, postprocess: { diff --git a/apps/web/src/app/(home)/_download/page.tsx b/apps/web/src/app/(home)/_download/page.tsx index 173cfa98..839a9ba4 100644 --- a/apps/web/src/app/(home)/_download/page.tsx +++ b/apps/web/src/app/(home)/_download/page.tsx @@ -1,19 +1,12 @@ "use client"; +import { Icon } from "@iconify-icon/react"; +import { Check, Code2, Download } from "lucide-react"; +import Image from "next/image"; import { useState } from "react"; -import { Navigation } from "@/components/landing/navigation"; import { FooterSection } from "@/components/landing/footer-section"; +import { Navigation } from "@/components/landing/navigation"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Check, Download, Code2 } from "lucide-react"; -import { Icon } from "@iconify-icon/react"; -import Image from "next/image"; type OS = "linux" | "macos" | "windows"; type Architecture = "x86_64" | "aarch64" | "arm64"; @@ -167,12 +160,12 @@ export default function DownloadPage() {
- Velopulent CMS Logo
@@ -195,12 +188,13 @@ export default function DownloadPage() {
{/* OS Selection */}
- +
{osOptions.map((option) => ( . All downloads are verified with SHA256 checksums. Need help? Check our{" "} - + .

diff --git a/apps/web/src/app/(home)/home.css b/apps/web/src/app/(home)/home.css index 1efd8ca3..546f5b37 100644 --- a/apps/web/src/app/(home)/home.css +++ b/apps/web/src/app/(home)/home.css @@ -1,5 +1,5 @@ -@import 'tailwindcss'; -@import 'tw-animate-css'; +@import "tailwindcss"; +@import "tw-animate-css"; @import "shadcn/tailwind.css"; @custom-variant dark (&:is(.dark *)); @@ -28,7 +28,7 @@ --chart-2: oklch(0.72 0.01 90); --chart-3: oklch(0.52 0.015 90); --chart-4: oklch(0.35 0.01 90); - --chart-5: oklch(0.20 0.008 90); + --chart-5: oklch(0.2 0.008 90); --radius: 0.25rem; --sidebar: oklch(0.06 0.008 260); --sidebar-foreground: oklch(0.94 0.005 90); @@ -41,9 +41,10 @@ } @theme inline { - --font-sans: var(--font-instrument), 'Instrument Sans', system-ui, sans-serif; - --font-mono: var(--font-jetbrains), 'JetBrains Mono', monospace; - --font-display: var(--font-instrument-serif), 'Instrument Serif', Georgia, serif; + --font-sans: var(--font-instrument), "Instrument Sans", system-ui, sans-serif; + --font-mono: var(--font-jetbrains), "JetBrains Mono", monospace; + --font-display: + var(--font-instrument-serif), "Instrument Serif", Georgia, serif; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -98,62 +99,72 @@ .font-display { font-family: var(--font-display); } - + .text-stroke { -webkit-text-stroke: 1.5px currentColor; -webkit-text-fill-color: transparent; } - + .marquee { animation: marquee 30s linear infinite; } - + .marquee-reverse { animation: marquee-reverse 25s linear infinite; } @keyframes marquee { - 0% { transform: translateX(0); } - 100% { transform: translateX(-50%); } + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } } - + @keyframes marquee-reverse { - 0% { transform: translateX(-50%); } - 100% { transform: translateX(0); } + 0% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0); + } } - + .line-reveal { clip-path: inset(0 100% 0 0); animation: line-reveal 0.8s cubic-bezier(0.77, 0, 0.175, 1) forwards; } - + @keyframes line-reveal { - to { clip-path: inset(0 0 0 0); } + to { + clip-path: inset(0 0 0 0); + } } - + .hover-lift { transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); } - + .hover-lift:hover { transform: translateY(-4px); } - + .letter-spin { display: inline-block; transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); } - + .letter-spin:hover { transform: rotateY(360deg); } - + .animate-char-in { animation: char-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; opacity: 0; transform: translateY(60%); } - + @keyframes char-in { 0% { opacity: 0; @@ -168,9 +179,9 @@ .noise-overlay { position: relative; } - + .noise-overlay::after { - content: ''; + content: ""; position: absolute; inset: 0; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); @@ -178,9 +189,9 @@ pointer-events: none; z-index: 1; } - + .how-it-works-bg { - background-color: oklch(0.10 0.008 260); + background-color: oklch(0.1 0.008 260); } .word-gradient { @@ -200,8 +211,14 @@ } @keyframes gradient-shift { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } } } diff --git a/apps/web/src/app/(home)/layout.tsx b/apps/web/src/app/(home)/layout.tsx index 94893fae..8f356971 100644 --- a/apps/web/src/app/(home)/layout.tsx +++ b/apps/web/src/app/(home)/layout.tsx @@ -1,10 +1,10 @@ -import React from "react"; import type { Metadata } from "next"; import { Instrument_Sans, Instrument_Serif, JetBrains_Mono, } from "next/font/google"; +import type React from "react"; import "./home.css"; import { Navigation } from "@/components/landing/navigation"; diff --git a/apps/web/src/app/(home)/page.tsx b/apps/web/src/app/(home)/page.tsx index c3504e21..7719e99e 100644 --- a/apps/web/src/app/(home)/page.tsx +++ b/apps/web/src/app/(home)/page.tsx @@ -1,16 +1,14 @@ -import { Navigation } from "@/components/landing/navigation"; -import { HeroSection } from "@/components/landing/hero-section"; +import { BackupSection } from "@/components/landing/backup-section"; +import { CtaSection } from "@/components/landing/cta-section"; +import { DashboardSection } from "@/components/landing/dashboard-section"; +import { DatabaseSection } from "@/components/landing/database-section"; import { FeaturesSection } from "@/components/landing/features-section"; +import { FooterSection } from "@/components/landing/footer-section"; +import { HeroSection } from "@/components/landing/hero-section"; import { HowItWorksSection } from "@/components/landing/how-it-works-section"; -import { DatabaseSection } from "@/components/landing/database-section"; -import { DashboardSection } from "@/components/landing/dashboard-section"; import { IntegrationsSection } from "@/components/landing/integrations-section"; import { SecuritySection } from "@/components/landing/security-section"; import { UseCasesSection } from "@/components/landing/use-cases"; -import { TestimonialsSection } from "@/components/landing/testimonials-section"; -import { CtaSection } from "@/components/landing/cta-section"; -import { FooterSection } from "@/components/landing/footer-section"; -import { BackupSection } from "@/components/landing/backup-section"; export default function Home() { return ( diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index aaaff7ff..c312b919 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -1,9 +1,9 @@ -import { source } from '@/lib/source'; -import { createFromSource } from 'fumadocs-core/search/server'; +import { createFromSource } from "fumadocs-core/search/server"; +import { source } from "@/lib/source"; export const revalidate = false; export const { staticGET: GET } = createFromSource(source, { // https://docs.orama.com/docs/orama-js/supported-languages - language: 'english', + language: "english", }); diff --git a/apps/web/src/app/docs/[[...slug]]/page.tsx b/apps/web/src/app/docs/[[...slug]]/page.tsx index 53ce219c..074d053a 100644 --- a/apps/web/src/app/docs/[[...slug]]/page.tsx +++ b/apps/web/src/app/docs/[[...slug]]/page.tsx @@ -1,4 +1,3 @@ -import { getPageImage, getPageMarkdownUrl, source } from '@/lib/source'; import { DocsBody, DocsDescription, @@ -6,14 +5,15 @@ import { DocsTitle, MarkdownCopyButton, ViewOptionsPopover, -} from 'fumadocs-ui/layouts/docs/page'; -import { notFound } from 'next/navigation'; -import { getMDXComponents } from '@/components/mdx'; -import type { Metadata } from 'next'; -import { createRelativeLink } from 'fumadocs-ui/mdx'; -import { gitConfig } from '@/lib/shared'; +} from "fumadocs-ui/layouts/docs/page"; +import { createRelativeLink } from "fumadocs-ui/mdx"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getMDXComponents } from "@/components/mdx"; +import { gitConfig } from "@/lib/shared"; +import { getPageImage, getPageMarkdownUrl, source } from "@/lib/source"; -export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { +export default async function Page(props: PageProps<"/docs/[[...slug]]">) { const params = await props.params; const page = source.getPage(params.slug); if (!page) notFound(); @@ -24,7 +24,9 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { return ( {page.data.title} - {page.data.description} + + {page.data.description} +
): Promise { +export async function generateMetadata( + props: PageProps<"/docs/[[...slug]]">, +): Promise { const params = await props.params; const page = source.getPage(params.slug); if (!page) notFound(); diff --git a/apps/web/src/app/docs/docs.css b/apps/web/src/app/docs/docs.css index f86f3c9d..da1cd2f0 100644 --- a/apps/web/src/app/docs/docs.css +++ b/apps/web/src/app/docs/docs.css @@ -1,12 +1,12 @@ -@import 'tailwindcss'; -@import 'fumadocs-ui/css/neutral.css'; -@import 'fumadocs-ui/css/preset.css'; +@import "tailwindcss"; +@import "fumadocs-ui/css/neutral.css"; +@import "fumadocs-ui/css/preset.css"; html { scrollbar-gutter: stable; } html > body[data-scroll-locked] { - margin-right: 0px !important; - --removed-body-scroll-bar-size: 0px !important; + margin-right: 0px; + --removed-body-scroll-bar-size: 0px; } diff --git a/apps/web/src/app/docs/layout.tsx b/apps/web/src/app/docs/layout.tsx index 5f237b0b..7ecb42d2 100644 --- a/apps/web/src/app/docs/layout.tsx +++ b/apps/web/src/app/docs/layout.tsx @@ -1,10 +1,10 @@ import "./docs.css"; -import { source } from "@/lib/source"; import { DocsLayout } from "fumadocs-ui/layouts/docs"; -import { baseOptions } from "@/lib/layout.shared"; -import { Provider as FumadocsProvider } from "@/components/provider"; import { Geist, Inter } from "next/font/google"; +import { Provider as FumadocsProvider } from "@/components/provider"; import { cn } from "@/lib/cn"; +import { baseOptions } from "@/lib/layout.shared"; +import { source } from "@/lib/source"; const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); diff --git a/apps/web/src/app/global.css b/apps/web/src/app/global.css index d4b50785..f1d8c73c 100644 --- a/apps/web/src/app/global.css +++ b/apps/web/src/app/global.css @@ -1 +1 @@ -@import 'tailwindcss'; +@import "tailwindcss"; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7802f899..f37e6c76 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,5 @@ import "./global.css"; -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Velopulent CMS", @@ -10,9 +10,7 @@ export const metadata: Metadata = { export default function Layout({ children }: LayoutProps<"/">) { return ( - - {children} - + {children} ); } diff --git a/apps/web/src/app/llms-full.txt/route.ts b/apps/web/src/app/llms-full.txt/route.ts index d494d2cb..fcccaeef 100644 --- a/apps/web/src/app/llms-full.txt/route.ts +++ b/apps/web/src/app/llms-full.txt/route.ts @@ -1,4 +1,4 @@ -import { getLLMText, source } from '@/lib/source'; +import { getLLMText, source } from "@/lib/source"; export const revalidate = false; @@ -6,5 +6,5 @@ export async function GET() { const scan = source.getPages().map(getLLMText); const scanned = await Promise.all(scan); - return new Response(scanned.join('\n\n')); + return new Response(scanned.join("\n\n")); } diff --git a/apps/web/src/app/llms.mdx/docs/[[...slug]]/route.ts b/apps/web/src/app/llms.mdx/docs/[[...slug]]/route.ts index 012e877c..f6f13190 100644 --- a/apps/web/src/app/llms.mdx/docs/[[...slug]]/route.ts +++ b/apps/web/src/app/llms.mdx/docs/[[...slug]]/route.ts @@ -1,9 +1,12 @@ -import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; -import { notFound } from 'next/navigation'; +import { notFound } from "next/navigation"; +import { getLLMText, getPageMarkdownUrl, source } from "@/lib/source"; export const revalidate = false; -export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { +export async function GET( + _req: Request, + { params }: RouteContext<"/llms.mdx/docs/[[...slug]]">, +) { const { slug } = await params; // remove the appended "content.md" const page = source.getPage(slug?.slice(0, -1)); @@ -11,7 +14,7 @@ export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/doc return new Response(await getLLMText(page), { headers: { - 'Content-Type': 'text/markdown', + "Content-Type": "text/markdown", }, }); } diff --git a/apps/web/src/app/llms.txt/route.ts b/apps/web/src/app/llms.txt/route.ts index fc80cb65..775be71f 100644 --- a/apps/web/src/app/llms.txt/route.ts +++ b/apps/web/src/app/llms.txt/route.ts @@ -1,5 +1,5 @@ -import { source } from '@/lib/source'; -import { llms } from 'fumadocs-core/source'; +import { llms } from "fumadocs-core/source"; +import { source } from "@/lib/source"; export const revalidate = false; diff --git a/apps/web/src/app/og/docs/[...slug]/route.tsx b/apps/web/src/app/og/docs/[...slug]/route.tsx index 877166d3..5e951cf1 100644 --- a/apps/web/src/app/og/docs/[...slug]/route.tsx +++ b/apps/web/src/app/og/docs/[...slug]/route.tsx @@ -1,18 +1,25 @@ -import { getPageImage, source } from '@/lib/source'; -import { notFound } from 'next/navigation'; -import { ImageResponse } from 'next/og'; -import { generate as DefaultImage } from 'fumadocs-ui/og'; -import { appName } from '@/lib/shared'; +import { generate as DefaultImage } from "fumadocs-ui/og"; +import { notFound } from "next/navigation"; +import { ImageResponse } from "next/og"; +import { appName } from "@/lib/shared"; +import { getPageImage, source } from "@/lib/source"; export const revalidate = false; -export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { +export async function GET( + _req: Request, + { params }: RouteContext<"/og/docs/[...slug]">, +) { const { slug } = await params; const page = source.getPage(slug.slice(0, -1)); if (!page) notFound(); return new ImageResponse( - , + , { width: 1200, height: 630, diff --git a/apps/web/src/components/landing/ascii-scene.tsx b/apps/web/src/components/landing/ascii-scene.tsx index b575f6d1..382a2455 100644 --- a/apps/web/src/components/landing/ascii-scene.tsx +++ b/apps/web/src/components/landing/ascii-scene.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; interface Point3D { x: number; @@ -52,7 +52,7 @@ export function AsciiScene() { p: number, q: number, segments: number, - tubeSegments: number + tubeSegments: number, ): Point3D[] => { const points: Point3D[] = []; for (let i = 0; i < segments; i++) { @@ -87,7 +87,7 @@ export function AsciiScene() { point: Point3D, angleX: number, angleY: number, - angleZ: number + angleZ: number, ): Point3D => { let { x, y, z } = point; @@ -117,7 +117,7 @@ export function AsciiScene() { point: Point3D, centerX: number, centerY: number, - scale: number + scale: number, ): { x: number; y: number; z: number } => { const perspective = 5; const factor = perspective / (perspective + point.z); @@ -184,7 +184,7 @@ export function AsciiScene() { ctx.fillText( ASCII_CHARS[Math.floor(pz * (ASCII_CHARS.length - 1))], px, - py + py, ); } diff --git a/apps/web/src/components/landing/backup-section.tsx b/apps/web/src/components/landing/backup-section.tsx index 131fe4d9..d352007b 100644 --- a/apps/web/src/components/landing/backup-section.tsx +++ b/apps/web/src/components/landing/backup-section.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState, useRef } from "react"; -import { HardDrive, Cloud, Shield, RotateCcw } from "lucide-react"; +import { Cloud, HardDrive, RotateCcw, Shield } from "lucide-react"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; const backupFeatures = [ { @@ -70,16 +71,17 @@ export function BackupSection() {
{/* Image — left column */}
- Backup and restore
@@ -107,7 +109,6 @@ export function BackupSection() { downtime.

-
@@ -118,6 +119,7 @@ export function BackupSection() { return (
- + Github @@ -91,13 +97,13 @@ export function CtaSection() { Open source, self-hosted

- {/* Right image */} -
- + Two trees connected by glowing arcs
diff --git a/apps/web/src/components/landing/dashboard-section.tsx b/apps/web/src/components/landing/dashboard-section.tsx index 8ecd4897..7ac72e68 100644 --- a/apps/web/src/components/landing/dashboard-section.tsx +++ b/apps/web/src/components/landing/dashboard-section.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; const dashboardCards = [ { @@ -23,66 +24,6 @@ const dashboardCards = [ }, ]; -function AnimatedNumber({ - end, - suffix = "", - prefix = "", -}: { - end: number; - suffix?: string; - prefix?: string; -}) { - const [count, setCount] = useState(0); - const [isScrambling, setIsScrambling] = useState(true); - const ref = useRef(null); - const [hasAnimated, setHasAnimated] = useState(false); - - useEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !hasAnimated) { - setHasAnimated(true); - const duration = 2500; - const startTime = performance.now(); - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - Math.pow(1 - progress, 4); - setCount(Math.floor(eased * end)); - setIsScrambling(progress < 0.8); - if (progress < 1) requestAnimationFrame(animate); - }; - requestAnimationFrame(animate); - } - }, - { threshold: 0.5 }, - ); - if (ref.current) observer.observe(ref.current); - return () => observer.disconnect(); - }, [end, hasAnimated]); - - const displayValue = count.toLocaleString(); - - return ( -
- {prefix} - - {displayValue.split("").map((char, i) => ( - - {char} - - ))} - - {suffix} -
- ); -} - function GridBackground() { const canvasRef = useRef(null); const timeRef = useRef(0); @@ -283,15 +224,17 @@ export function DashboardSection() { {/* Organic graph image */}
-
diff --git a/apps/web/src/components/landing/database-section.tsx b/apps/web/src/components/landing/database-section.tsx index bbce36d0..15dcf321 100644 --- a/apps/web/src/components/landing/database-section.tsx +++ b/apps/web/src/components/landing/database-section.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; const regions = [ { name: "SQLite", nodes: 0, status: "supported" }, @@ -19,7 +20,7 @@ export function DatabaseSection() { ([entry]) => { if (entry.isIntersecting) setIsVisible(true); }, - { threshold: 0.1 } + { threshold: 0.1 }, ); if (sectionRef.current) observer.observe(sectionRef.current); @@ -33,46 +34,78 @@ export function DatabaseSection() { return () => clearInterval(interval); }, []); + const lineConfigs = Array.from({ length: 19 }, (_, i) => ({ + x1: 10 + (i % 5) * 20, + y1: 10 + Math.floor(i / 5) * 25, + x2: 10 + ((i + 1) % 5) * 20, + y2: 10 + Math.floor((i + 1) / 5) * 25, + delay: i * 0.15, + })); + + const dotConfigs = Array.from({ length: 20 }, (_, i) => ({ + left: 10 + (i % 5) * 20, + top: 10 + Math.floor(i / 5) * 25, + delay: i * 0.1, + })); + return ( -
- {/* Background accent — retiré, remplacé par l'image sphère */} - +
+ {/* Background accent — retiré, remplacé par l'image sphère */} +
{/* Header */}
- + Database Support - +
{/* Image globe — colonne gauche, pleine hauteur */} -
- + Global network sphere
{/* Titre + description empilés */}
-

+

Your choice
of database.

-

- Embedded SQLite for lightweight workloads, MySQL for scalable deployments, PostgreSQL for complex, high-volume systems. +

+ Embedded SQLite for lightweight workloads, MySQL for scalable + deployments, PostgreSQL for complex, high-volume systems.

@@ -81,13 +114,18 @@ export function DatabaseSection() { {/* Main content grid */}
{/* Large stat card */} -
+
{/* Animated dots background with connecting lines */}
{/* SVG for connecting lines */} {/* Dots */} - {[...Array(20)].map((_, i) => ( + {dotConfigs.map((dot) => (
))}
- +
- 3 - databases + + 3 + + + databases +

- Built-in support for the most popular databases. Configure once, scale anywhere. + Built-in support for the most popular databases. Configure once, + scale anywhere.

{/* Stacked stat cards */}
-
- < 20MB - Binary size +
+ + < 20MB + + + Binary size +
- -
+ +
- Projects per instance + + Projects per instance +
{/* Region list */} -
+
{regions.map((region, index) => (
- + {region.status} diff --git a/apps/web/src/components/landing/features-section.tsx b/apps/web/src/components/landing/features-section.tsx index b3786494..b82b217f 100644 --- a/apps/web/src/components/landing/features-section.tsx +++ b/apps/web/src/components/landing/features-section.tsx @@ -1,30 +1,35 @@ "use client"; +import Image from "next/image"; import { useEffect, useRef, useState } from "react"; const features = [ { number: "01", title: "Single Binary Deployment", - description: "Deploy Velopulent CMS as a single binary with built-in embedded dashboard. No dependencies, no complex setup required. Perfect for individuals, agencies, and enterprises.", + description: + "Deploy Velopulent CMS as a single binary with built-in embedded dashboard. No dependencies, no complex setup required. Perfect for individuals, agencies, and enterprises.", stats: { value: "<15MB", label: "memory footprint" }, }, { number: "02", title: "Multi-Protocol Support", - description: "Built-in REST, GraphQL, gRPC and MCP protocols in a single binary. Connect to any framework or tech stack with zero integration hassle.", + description: + "Built-in REST, GraphQL, gRPC and MCP protocols in a single binary. Connect to any framework or tech stack with zero integration hassle.", stats: { value: "4", label: "protocols supported" }, }, { number: "03", title: "Flexible Database Support", - description: "Works seamlessly with SQLite, MySQL, PostgreSQL and more. Configure once and scale across your infrastructure.", + description: + "Works seamlessly with SQLite, MySQL, PostgreSQL and more. Configure once and scale across your infrastructure.", stats: { value: "3+", label: "database engines" }, }, { number: "04", title: "Multi-Project Management", - description: "Manage multiple sites and projects from a single instance. Built-in access control with admin, editor, and viewer roles.", + description: + "Manage multiple sites and projects from a single instance. Built-in access control with admin, editor, and viewer roles.", stats: { value: "∞", label: "projects supported" }, }, ]; @@ -65,8 +70,8 @@ function ParticleVisualization() { const particles = Array.from({ length: COUNT }, (_, i) => { const seed = i * 1.618; return { - bx: ((seed * 127.1) % 1), - by: ((seed * 311.7) % 1), + bx: (seed * 127.1) % 1, + by: (seed * 311.7) % 1, phase: seed * Math.PI * 2, speed: 0.4 + (seed % 0.4), radius: 1.2 + (seed % 2.2), @@ -130,7 +135,7 @@ function ParticleVisualization() { export function FeaturesSection() { const [isVisible, setIsVisible] = useState(false); - const [activeFeature, setActiveFeature] = useState(0); + const [_activeFeature, setActiveFeature] = useState(0); const sectionRef = useRef(null); useEffect(() => { @@ -138,7 +143,7 @@ export function FeaturesSection() { ([entry]) => { if (entry.isIntersecting) setIsVisible(true); }, - { threshold: 0.1 } + { threshold: 0.1 }, ); if (sectionRef.current) observer.observe(sectionRef.current); @@ -162,7 +167,9 @@ export function FeaturesSection() {

Powerful @@ -171,10 +178,16 @@ export function FeaturesSection() {

-

- Everything you need to manage content at scale. From single projects to enterprise deployments with multiple sites and teams. +

+ Everything you need to manage content at scale. From single + projects to enterprise deployments with multiple sites and + teams.

@@ -183,17 +196,22 @@ export function FeaturesSection() { {/* Bento Grid Layout */}
{/* Large feature card */} -
setActiveFeature(0)} > {/* Left: text content */}
- {features[0].number} + + {features[0].number} +

{features[0].title}

@@ -201,19 +219,24 @@ export function FeaturesSection() { {features[0].description}

- {features[0].stats.value} - {features[0].stats.label} + + {features[0].stats.value} + + + {features[0].stats.label} +
{/* Right: mirrored image, full height */}
- {/* Fade left edge into black */} diff --git a/apps/web/src/components/landing/footer-section.tsx b/apps/web/src/components/landing/footer-section.tsx index f6384af8..b68f0b2c 100644 --- a/apps/web/src/components/landing/footer-section.tsx +++ b/apps/web/src/components/landing/footer-section.tsx @@ -1,8 +1,8 @@ "use client"; import { ArrowUpRight } from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; -import { useEffect, useRef } from "react"; const footerLinks = { Product: [ @@ -42,71 +42,16 @@ const socialLinks = [ { name: "Youtube", href: "https://youtube.com/@velopulent" }, ]; -function AnimatedWaveCanvas() { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - let animationId: number; - let time = 0; - - const resize = () => { - canvas.width = canvas.offsetWidth * window.devicePixelRatio; - canvas.height = canvas.offsetHeight * window.devicePixelRatio; - ctx.scale(window.devicePixelRatio, window.devicePixelRatio); - }; - resize(); - window.addEventListener("resize", resize); - - const animate = () => { - const width = canvas.offsetWidth; - const height = canvas.offsetHeight; - ctx.clearRect(0, 0, width, height); - - ctx.strokeStyle = "rgba(100, 200, 150, 0.3)"; - ctx.lineWidth = 1; - - for (let wave = 0; wave < 3; wave++) { - ctx.beginPath(); - for (let x = 0; x <= width; x += 5) { - const y = - height * 0.5 + - Math.sin(x * 0.01 + time + wave * 0.5) * 30 + - Math.sin(x * 0.02 + time * 1.5 + wave) * 20; - if (x === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - } - - time += 0.02; - animationId = requestAnimationFrame(animate); - }; - animate(); - - return () => { - window.removeEventListener("resize", resize); - cancelAnimationFrame(animationId); - }; - }, []); - - return ; -} - export function FooterSection() { return (