diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29f9ee39..3ddd986a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,153 +1,237 @@ -name: CI - -# IMPORTANT: using Swatinem/rust-cache@v2 : reuse same cache as playwright.yml (minimize non necessary new compilation) - -on: - push: - branches: - - main - - dev - - "feat/**" - - "fix/**" - pull_request: - branches: [ "main", "dev" ] - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-Dwarnings" - DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test" - -jobs: - - # Detect which parts of the codebase changed - changes: - runs-on: ubuntu-latest - outputs: - frontend: ${{ steps.filter.outputs.frontend }} - backend: ${{ steps.filter.outputs.backend }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - frontend: - - 'static/**' - - 'biome.json' - - '.grit' - - '.stylelintrc.json' - - 'jsconfig.json' - backend: - - 'src/**' - - 'Cargo.toml' - - 'Cargo.lock' - - frontend-linter: - name: Frontend — CSS and JS checks (format, lint, rules, etc) - needs: changes - if: needs.changes.outputs.frontend == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Biome - uses: biomejs/setup-biome@v2 - - - name: Run Biome check - run: biome ci static/ - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 25 - - # because we are not using package.json - - name: Install Stylelint and plugins - run: | - npm install --global \ - stylelint@17 \ - postcss@8 \ - stylelint-value-no-unknown-custom-properties@6 - - - name: Run Stylelint - run: npx stylelint "static/css/**/*.{css,scss}" - - rust-fmt: - name: Rustfmt - needs: changes - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - run: cargo fmt --all --check - - rust-clippy: - name: Clippy - needs: changes - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets --all-features -- -D warnings - - rust-test: - name: Tests - needs: changes - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: oxicloud_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - - name: Initialize test database - run: psql -h localhost -U postgres -d oxicloud_test -f migrations/20260307000000_initial_schema.sql - env: - PGPASSWORD: postgres - - - name: Run tests - run: cargo test --all-features --workspace - env: - DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test" - - rust-audit: - name: Security Audit - needs: changes - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: rustsec/audit-check@v2.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - build: - name: Build Check - runs-on: ubuntu-latest - needs: [frontend-linter, rust-fmt, rust-clippy, rust-test] - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - run: cargo build --release +name: CI + +on: + push: + branches: + - main + - dev + - "feat/**" + - "fix/**" + pull_request: + branches: [ "main", "dev" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test" + +jobs: + + # Detect which parts of the codebase changed + changes: + runs-on: ubuntu-latest + outputs: + frontend: ${{ steps.filter.outputs.frontend }} + backend: ${{ steps.filter.outputs.backend }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + frontend: + - 'static/**' + - 'biome.json' + - '.grit' + - '.stylelintrc.json' + - 'jsconfig.json' + backend: + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + + frontend-check: + name: Frontend — CSS and JS checks (format, lint, rules) + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Biome + uses: biomejs/setup-biome@v2 + + - name: Run Biome check + run: biome ci static/ + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 25 + + # because we are not using package.json + - name: Install Stylelint and plugins + run: | + npm install --global \ + stylelint@17 \ + postcss@8 \ + stylelint-value-no-unknown-custom-properties@6 + + - name: Run Stylelint + run: npx stylelint "static/css/**/*.{css,scss}" + + rust-fmt: + name: Rustfmt + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check + + rust-clippy: + name: Clippy + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets --all-features -- -D warnings + + rust-test: + name: Server Unit and Functionnal Tests + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: oxicloud_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Initialize test database + run: psql -h localhost -U postgres -d oxicloud_test -f migrations/20260307000000_initial_schema.sql + env: + PGPASSWORD: postgres + + - name: Run tests + run: cargo test --all-features --workspace + env: + DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test" + + rust-audit: + name: Security Audit + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: rustsec/audit-check@v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + build: + name: Build + runs-on: ubuntu-latest + needs: [rust-fmt, rust-clippy, rust-test] + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release + - uses: actions/upload-artifact@v4 + with: + name: oxicloud-release + path: target/release/oxicloud + retention-days: 1 + + api-test: + name: API tests (via Hurl) + needs: build + if: github.event_name == 'pull_request' + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: oxicloud-release + path: target/release/ + + - run: chmod +x target/release/oxicloud + + - name: Install Hurl + env: + HURL_MAJOR: "8" + run: | + HURL_VERSION=$(curl -fsSL -H "Authorization: Bearer ${{ github.token }}" \ + https://api.github.com/repos/Orange-OpenSource/hurl/releases \ + | jq -r "map(select(.tag_name | startswith(\"${HURL_MAJOR}.\"))) | first | .tag_name") + curl -fLO "https://github.com/Orange-OpenSource/hurl/releases/download/${HURL_VERSION}/hurl_${HURL_VERSION}_amd64.deb" + sudo apt-get install -y "./hurl_${HURL_VERSION}_amd64.deb" + + - name: Run Hurl API tests + run: bash tests/api/run.sh + env: + BUILD_TARGET: release + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: hurl-report + path: tests/api/storage/ + retention-days: 7 + + front-test: + name: Frontend end-to-end tests (via Playwright) + # ensure that api tests are ok before + needs: api-test + if: github.event_name == 'pull_request' + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: oxicloud-release + path: target/release/ + + - run: chmod +x target/release/oxicloud + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install Node dependencies + working-directory: tests/e2e + run: npm ci + + - name: Install Playwright browsers + working-directory: tests/e2e + run: npx playwright install --with-deps + + - name: Run Playwright tests (spawns DB via pretest hook) + working-directory: tests/e2e + run: npm test + env: + BUILD_TARGET: release + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: tests/e2e/playwright-report/ + retention-days: 30 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 54dbadb7..2c9c71ca 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -6,9 +6,9 @@ on: pull_request: branches: [ "main", "dev" ] -concurrency: - group: rust-build-${{ github.ref }} - cancel-in-progress: false +#concurrency: +# group: rust-build-${{ github.ref }} +# cancel-in-progress: false jobs: build-and-test: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 999950f5..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Playwright Test (end-to-end) - -# IMPORTANT: using Swatinem/rust-cache@v2 : reuse same cache as ci.yml (minimize non necessary new compilation) - -on: - push: - branches: [ main, dev ] - pull_request: - branches: [ main, dev ] - -concurrency: - group: rust-build-${{ github.ref }} - cancel-in-progress: false - -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache compiled binary - id: binary-cache - uses: actions/cache@v4 - with: - path: ${{ github.workspace }}/target/debug/oxicloud - key: ${{ runner.os }}-oxicloud-binary-${{ hashFiles('src/**', 'Cargo.toml', 'Cargo.lock') }} - - - name: Cache Rust dependencies - if: steps.binary-cache.outputs.cache-hit != 'true' - uses: Swatinem/rust-cache@v2 - - - name: Build server - if: steps.binary-cache.outputs.cache-hit != 'true' - run: cargo build - - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - - name: Install Node dependencies - working-directory: tests/e2e - run: npm ci - - - name: Install Playwright browsers - working-directory: tests/e2e - run: npx playwright install --with-deps - - - name: Run Playwright tests (spawns DB via pretest hook) - working-directory: tests/e2e - run: npm test - - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: tests/e2e/playwright-report/ - retention-days: 30 diff --git a/Dockerfile b/Dockerfile index 495c9b38..8caf1479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,10 +67,10 @@ WORKDIR /app # Expose application port EXPOSE 8086 -# Basic health check — verifies the HTTP server responds on the main port. +# Liveness probe — verifies the HTTP server is up (no DB check, fast). # Docker / Compose / Swarm will mark the container unhealthy after 3 failures. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:8086/api/version || exit 1 + CMD wget -qO- http://localhost:8086/health || exit 1 # Entrypoint fixes volume permissions then drops to oxicloud user. # The container starts as root so it can chown mounted volumes, diff --git a/docker-compose.yml b/docker-compose.yml index 0fe491d9..4a1d3353 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,12 @@ services: - .env volumes: - storage_data:/app/storage + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8086/ready || exit 1"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 networks: oxicloud: diff --git a/docs/config/env.md b/docs/config/env.md index e207cb87..158d55c5 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -10,7 +10,8 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_STATIC_PATH` | `./static` | Static files directory | | `OXICLOUD_SERVER_PORT` | `8086` | Server port | | `OXICLOUD_SERVER_HOST` | `127.0.0.1` | Server bind address | -| `OXICLOUD_BASE_URL` | (auto) | Public base URL for share links | +| `OXICLOUD_BASE_URL` | (auto) | Public base URL for share links; defaults to `http://{host}:{port}` | +| `OXICLOUD_MAX_UPLOAD_SIZE` | `10737418240` | Maximum upload size in bytes (10 GB on 64-bit, 1 GB on 32-bit) | ## Database @@ -32,9 +33,25 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | Variable | Default | Description | |---|---|---| -| `OXICLOUD_JWT_SECRET` | (random) | JWT signing secret | -| `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (seconds) | -| `OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS` | `2592000` | Refresh token lifetime (seconds) | +| `OXICLOUD_JWT_SECRET` | (auto-generated) | JWT signing secret; auto-persisted to `/.jwt_secret` if unset | +| `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (1 hour) | +| `OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS` | `604800` | Refresh token lifetime (7 days); active sessions auto-renew on use | +| `OXICLOUD_HASH_MEMORY_COST` | `65536` | Argon2id memory cost in KiB (64 MiB) | +| `OXICLOUD_HASH_TIME_COST` | `3` | Argon2id iteration count | +| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes | + +### Rate Limiting & Account Lockout + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_RATE_LIMIT_LOGIN_MAX` | `10` | Max login attempts per IP per window | +| `OXICLOUD_RATE_LIMIT_LOGIN_WINDOW_SECS` | `60` | Login rate-limit window (seconds) | +| `OXICLOUD_RATE_LIMIT_REGISTER_MAX` | `5` | Max registration attempts per IP per window | +| `OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS` | `3600` | Registration rate-limit window (seconds) | +| `OXICLOUD_RATE_LIMIT_REFRESH_MAX` | `20` | Max token refresh attempts per IP per window | +| `OXICLOUD_RATE_LIMIT_REFRESH_WINDOW_SECS` | `60` | Refresh rate-limit window (seconds) | +| `OXICLOUD_LOCKOUT_MAX_FAILURES` | `5` | Consecutive failed logins before account lockout | +| `OXICLOUD_LOCKOUT_DURATION_SECS` | `900` | Account lockout duration (15 minutes) | ## Feature Flags @@ -44,7 +61,70 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` | `false` | Per-user storage quotas | | `OXICLOUD_ENABLE_FILE_SHARING` | `true` | File/folder sharing | | `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin | -| `OXICLOUD_ENABLE_SEARCH` | `true` | Search | +| `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search | +| `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata | +| `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` | + +## Storage Backend + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_STORAGE_BACKEND` | `local` | Blob storage backend: `local`, `s3`, or `azure` | + +### S3-Compatible (AWS S3, Backblaze B2, Cloudflare R2, MinIO) + +Used when `OXICLOUD_STORAGE_BACKEND=s3`. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_S3_BUCKET` | — | S3 bucket name (required) | +| `OXICLOUD_S3_REGION` | `us-east-1` | AWS region | +| `OXICLOUD_S3_ACCESS_KEY` | — | Access key ID | +| `OXICLOUD_S3_SECRET_KEY` | — | Secret access key | +| `OXICLOUD_S3_ENDPOINT_URL` | — | Custom endpoint for non-AWS providers (e.g. `https://s3.example.com`) | +| `OXICLOUD_S3_FORCE_PATH_STYLE` | `false` | Force path-style URLs (required for MinIO, R2) | + +### Azure Blob Storage + +Used when `OXICLOUD_STORAGE_BACKEND=azure`. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_AZURE_ACCOUNT_NAME` | — | Storage account name (required) | +| `OXICLOUD_AZURE_ACCOUNT_KEY` | — | Storage account key | +| `OXICLOUD_AZURE_CONTAINER` | — | Blob container name (required) | +| `OXICLOUD_AZURE_SAS_TOKEN` | — | SAS token (alternative to account key) | + +### Local Disk Cache for Remote Backends + +A least-recently-used disk cache that can speed up repeated reads from S3 or Azure. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_STORAGE_CACHE_ENABLED` | `false` | Enable LRU disk cache | +| `OXICLOUD_STORAGE_CACHE_MAX_SIZE` | `53687091200` | Max cache size in bytes (50 GB) | +| `OXICLOUD_STORAGE_CACHE_PATH` | `{STORAGE_PATH}/.blob-cache` | Cache directory | + +### Client-Side Encryption + +AES-256-GCM encryption applied to blobs before they are written to any backend. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_STORAGE_ENCRYPTION_ENABLED` | `false` | Enable at-rest blob encryption | +| `OXICLOUD_STORAGE_ENCRYPTION_KEY` | — | Base64-encoded 32-byte encryption key; generate with `openssl rand -base64 32` | + +### Retry Policy (Remote Backends) + +Exponential backoff retries for transient errors on S3 and Azure. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_STORAGE_RETRY_ENABLED` | `true` | Enable retry with exponential backoff | +| `OXICLOUD_STORAGE_RETRY_MAX_RETRIES` | `3` | Maximum retry attempts | +| `OXICLOUD_STORAGE_RETRY_INITIAL_BACKOFF_MS` | `100` | Initial backoff in milliseconds | +| `OXICLOUD_STORAGE_RETRY_MAX_BACKOFF_MS` | `10000` | Maximum backoff cap in milliseconds | +| `OXICLOUD_STORAGE_RETRY_BACKOFF_MULTIPLIER` | `2.0` | Backoff multiplier per retry | ## OIDC / SSO @@ -56,13 +136,13 @@ See the [OIDC configuration guide](/config/oidc) for details. | `OXICLOUD_OIDC_ISSUER_URL` | — | OIDC issuer URL | | `OXICLOUD_OIDC_CLIENT_ID` | — | Client ID | | `OXICLOUD_OIDC_CLIENT_SECRET` | — | Client secret | -| `OXICLOUD_OIDC_REDIRECT_URI` | `http://localhost:8086/api/auth/oidc/callback` | Callback URL | +| `OXICLOUD_OIDC_REDIRECT_URI` | `http://localhost:8086/api/auth/oidc/callback` | Callback URL (must match IdP config) | | `OXICLOUD_OIDC_SCOPES` | `openid profile email` | Requested scopes | -| `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Frontend URL | -| `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first SSO login | -| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Groups that grant admin role | -| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password form when OIDC enabled | -| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider | +| `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Frontend URL to redirect to after login | +| `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first SSO login (JIT provisioning) | +| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Comma-separated OIDC groups that grant admin role | +| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password form when OIDC is active | +| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider shown in UI | ## WOPI (Office Editing) @@ -73,21 +153,40 @@ See the [WOPI configuration guide](/config/wopi) for details. | `OXICLOUD_WOPI_ENABLED` | `false` | Enable WOPI | | `OXICLOUD_WOPI_DISCOVERY_URL` | — | Collabora/OnlyOffice discovery URL | | `OXICLOUD_WOPI_BASE_URL` | `OXICLOUD_BASE_URL` | URL the editor uses to call OxiCloud's `/wopi/*` endpoints | -| `OXICLOUD_WOPI_PUBLIC_BASE_URL` | `OXICLOUD_WOPI_BASE_URL` | URL the browser uses to open OxiCloud's WOPI host page and `postMessage` origin | +| `OXICLOUD_WOPI_PUBLIC_BASE_URL` | `OXICLOUD_WOPI_BASE_URL` | URL the browser uses to open OxiCloud's WOPI host page | | `OXICLOUD_WOPI_SECRET` | (JWT secret) | WOPI token signing key | -| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Token lifetime | -| `OXICLOUD_WOPI_LOCK_TTL_SECS` | `1800` | Lock expiration | +| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Token lifetime (24 hours) | +| `OXICLOUD_WOPI_LOCK_TTL_SECS` | `1800` | Lock expiration (30 minutes) | When Collabora or OnlyOffice runs on a different hostname, set `OXICLOUD_WOPI_PUBLIC_BASE_URL` to the public OxiCloud URL that the browser can reach. If the editor reaches OxiCloud through a different internal URL, also set `OXICLOUD_WOPI_BASE_URL` for those callbacks. +## Nextcloud Compatibility + +Enables the Nextcloud-compatible API layer (`/remote.php/`, `/ocs/`, `/status.php`, Login Flow v2) for clients that use the Nextcloud protocol. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_NEXTCLOUD_ENABLED` | `false` | Enable Nextcloud compatibility layer | +| `OXICLOUD_NEXTCLOUD_INSTANCE_ID` | `ocnca` | Instance ID suffix used in `oc:id` formatting | +| `OXICLOUD_NEXTCLOUD_VERSION` | `28.0.4` | Emulated Nextcloud version reported to clients (format: `major.minor.patch`) | + +## Trusted Proxy + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_TRUST_PROXY_CIDR` | — | Comma-separated list of trusted proxy CIDRs; enables `X-Forwarded-For` / `X-Real-IP` extraction for those source IPs | +| `OXICLOUD_TRUST_PROXY_HEADERS` | — | **Deprecated.** Use `OXICLOUD_TRUST_PROXY_CIDR` instead | + +Example: `OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12` + ## Allocator Tuning These variables are read directly by **mimalloc**, not by OxiCloud's config parser. | Variable | Default | Description | |---|---|---| -| `MIMALLOC_PURGE_DELAY` | `0` | Delay in ms before freed memory is returned to the OS | -| `MIMALLOC_ALLOW_LARGE_OS_PAGES` | `0` | Enable or disable large OS pages for allocations | +| `MIMALLOC_PURGE_DELAY` | `0` | Delay in ms before freed memory is returned to the OS (`0` = immediately, recommended for Docker) | +| `MIMALLOC_ALLOW_LARGE_OS_PAGES` | `0` | Enable 2 MiB huge pages (`0` = off, recommended for Docker to avoid THP RSS inflation) | ## Internal Defaults (not configurable via env) @@ -100,5 +199,6 @@ These variables are read directly by **mimalloc**, not by OxiCloud's config pars | Streaming chunk size | 1 MB | | Max parallel chunks | 8 | | Trash retention | 30 days | -| Argon2id memory cost | 64 MB | +| Argon2id memory cost | 64 MiB | | Argon2id time cost | 3 iterations | +| Nextcloud Login Flow v2 TTL | 600 s | diff --git a/example.env b/example.env index 8f61608e..f330abf8 100644 --- a/example.env +++ b/example.env @@ -30,6 +30,9 @@ OXICLOUD_SERVER_HOST=127.0.0.1 # Example: https://cloud.example.com #OXICLOUD_BASE_URL=https://cloud.example.com +# Maximum upload size in bytes (default: 10 GB on 64-bit) +#OXICLOUD_MAX_UPLOAD_SIZE=10737418240 + # ----------------------------------------------------------------------------- # DATABASE CONFIGURATION # ----------------------------------------------------------------------------- @@ -48,8 +51,7 @@ OXICLOUD_DB_CONNECTION_STRING=postgres://postgres:postgres@postgres/oxicloud # Maximum connections for the maintenance pool (background/batch tasks). # This pool is isolated from user requests, preventing background operations -# (verify_integrity, garbage_collect, storage recalculation) from starving -# interactive traffic. Default: 5 +# from starving interactive traffic. Default: 5 #OXICLOUD_DB_MAINTENANCE_MAX_CONNECTIONS=5 # Minimum connections for the maintenance pool. Default: 1 @@ -74,8 +76,43 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Access token lifetime in seconds (default: 3600 = 1 hour) #OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS=3600 -# Refresh token lifetime in seconds (default: 2592000 = 30 days) -#OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS=2592000 +# Refresh token lifetime in seconds (default: 604800 = 7 days) +# Active sessions auto-renew on use via token rotation, so users stay logged in +# as long as they interact within this window. +#OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS=604800 + +# Argon2id password hashing parameters +# Increase memory cost for stronger hashing at the expense of login latency. +# Memory cost is in KiB (default: 65536 = 64 MiB) +#OXICLOUD_HASH_MEMORY_COST=65536 +# Number of iterations (default: 3) +#OXICLOUD_HASH_TIME_COST=3 +# Parallelism lanes (default: 2) +#OXICLOUD_HASH_PARALLELISM=2 + +# ----------------------------------------------------------------------------- +# RATE LIMITING & ACCOUNT LOCKOUT +# ----------------------------------------------------------------------------- + +# Max login attempts per IP before rate-limiting kicks in (default: 10) +#OXICLOUD_RATE_LIMIT_LOGIN_MAX=10 +# Rate-limit window for logins in seconds (default: 60) +#OXICLOUD_RATE_LIMIT_LOGIN_WINDOW_SECS=60 + +# Max registration attempts per IP per window (default: 5) +#OXICLOUD_RATE_LIMIT_REGISTER_MAX=5 +# Rate-limit window for registrations in seconds (default: 3600) +#OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS=3600 + +# Max token refresh attempts per IP per window (default: 20) +#OXICLOUD_RATE_LIMIT_REFRESH_MAX=20 +# Rate-limit window for token refresh in seconds (default: 60) +#OXICLOUD_RATE_LIMIT_REFRESH_WINDOW_SECS=60 + +# Consecutive failed logins before account lockout (default: 5) +#OXICLOUD_LOCKOUT_MAX_FAILURES=5 +# Account lockout duration in seconds (default: 900 = 15 minutes) +#OXICLOUD_LOCKOUT_DURATION_SECS=900 # ----------------------------------------------------------------------------- # FEATURE FLAGS @@ -96,6 +133,85 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Enable search functionality (default: true) #OXICLOUD_ENABLE_SEARCH=true +# Enable music playlists and audio metadata (default: true) +#OXICLOUD_ENABLE_MUSIC=true + +# Expose other OxiCloud users as a read-only "system" address book +# at GET /api/address-books (default: true) +# Set to false to prevent users from browsing the user directory. +#OXICLOUD_EXPOSE_SYSTEM_USERS=true + +# ----------------------------------------------------------------------------- +# STORAGE BACKEND +# ----------------------------------------------------------------------------- + +# Blob storage backend: local (default), s3, or azure +#OXICLOUD_STORAGE_BACKEND=local + +# --- S3-Compatible (AWS S3, Backblaze B2, Cloudflare R2, MinIO) --- +# Used when OXICLOUD_STORAGE_BACKEND=s3 + +# S3 bucket name (required) +#OXICLOUD_S3_BUCKET=my-oxicloud-bucket + +# AWS region (default: us-east-1) +#OXICLOUD_S3_REGION=us-east-1 + +# Access credentials +#OXICLOUD_S3_ACCESS_KEY= +#OXICLOUD_S3_SECRET_KEY= + +# Custom endpoint for non-AWS providers (e.g. MinIO, R2, B2) +#OXICLOUD_S3_ENDPOINT_URL=https://s3.example.com + +# Force path-style URLs — required for MinIO, Cloudflare R2 (default: false) +#OXICLOUD_S3_FORCE_PATH_STYLE=false + +# --- Azure Blob Storage --- +# Used when OXICLOUD_STORAGE_BACKEND=azure + +# Storage account name (required) +#OXICLOUD_AZURE_ACCOUNT_NAME= +# Storage account key (or use SAS token below) +#OXICLOUD_AZURE_ACCOUNT_KEY= +# Blob container name (required) +#OXICLOUD_AZURE_CONTAINER=oxicloud +# SAS token (alternative to account key) +#OXICLOUD_AZURE_SAS_TOKEN= + +# --- Local Disk Cache for Remote Backends --- +# LRU cache that speeds up repeated reads from S3 or Azure. + +# Enable disk cache (default: false) +#OXICLOUD_STORAGE_CACHE_ENABLED=false +# Maximum cache size in bytes (default: 53687091200 = 50 GB) +#OXICLOUD_STORAGE_CACHE_MAX_SIZE=53687091200 +# Cache directory (default: {STORAGE_PATH}/.blob-cache) +#OXICLOUD_STORAGE_CACHE_PATH= + +# --- Client-Side Encryption --- +# AES-256-GCM encryption applied to blobs before writing to any backend. +# WARNING: losing the key means losing all data. Back it up securely. + +# Enable at-rest blob encryption (default: false) +#OXICLOUD_STORAGE_ENCRYPTION_ENABLED=false +# Base64-encoded 32-byte key; generate with: openssl rand -base64 32 +#OXICLOUD_STORAGE_ENCRYPTION_KEY= + +# --- Retry Policy (Remote Backends) --- +# Exponential backoff retries for transient errors on S3 and Azure. + +# Enable retry (default: true) +#OXICLOUD_STORAGE_RETRY_ENABLED=true +# Maximum number of retry attempts (default: 3) +#OXICLOUD_STORAGE_RETRY_MAX_RETRIES=3 +# Initial backoff in milliseconds (default: 100) +#OXICLOUD_STORAGE_RETRY_INITIAL_BACKOFF_MS=100 +# Maximum backoff cap in milliseconds (default: 10000) +#OXICLOUD_STORAGE_RETRY_MAX_BACKOFF_MS=10000 +# Backoff multiplier per retry (default: 2.0) +#OXICLOUD_STORAGE_RETRY_BACKOFF_MULTIPLIER=2.0 + # ----------------------------------------------------------------------------- # OPENID CONNECT (OIDC) / SSO CONFIGURATION # ----------------------------------------------------------------------------- @@ -170,6 +286,37 @@ OXICLOUD_WOPI_ENABLED=false # WOPI lock expiration in seconds (default: 1800 = 30 minutes) #OXICLOUD_WOPI_LOCK_TTL_SECS=1800 +# ----------------------------------------------------------------------------- +# NEXTCLOUD COMPATIBILITY +# ----------------------------------------------------------------------------- +# Enables the Nextcloud-compatible API layer for clients that speak the +# Nextcloud protocol (desktop sync, mobile apps, Nextcloud Talk, etc.) + +# Enable Nextcloud compatibility (default: false) +#OXICLOUD_NEXTCLOUD_ENABLED=false + +# Instance ID suffix used in oc:id formatting (default: ocnca) +#OXICLOUD_NEXTCLOUD_INSTANCE_ID=ocnca + +# Emulated Nextcloud version reported to clients (default: 28.0.4) +# Clients use this to decide which protocol features to enable. +#OXICLOUD_NEXTCLOUD_VERSION=28.0.4 + +# ----------------------------------------------------------------------------- +# PROXY +# ----------------------------------------------------------------------------- + +# Use this section if you are running OxiCloud behind a reverse proxy. + +# Trusted Proxy CIDRs — comma-separated list of CIDR blocks whose +# X-Forwarded-For / X-Real-IP headers will be trusted for client IP detection. +# Leave unset if OxiCloud is directly exposed (no proxy). +# Example: 127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,::1/128 +#OXICLOUD_TRUST_PROXY_CIDR= + +# DEPRECATED — use OXICLOUD_TRUST_PROXY_CIDR instead +#OXICLOUD_TRUST_PROXY_HEADERS= + # ----------------------------------------------------------------------------- # MEMORY ALLOCATOR TUNING (IMPORTANT FOR RAM USAGE) # ----------------------------------------------------------------------------- @@ -201,16 +348,3 @@ MIMALLOC_PURGE_DELAY=0 # When enabled with Linux Transparent Huge Pages (THP), partially-used 2 MiB # pages inflate the reported RSS by up to 20-30 MiB. MIMALLOC_ALLOW_LARGE_OS_PAGES=0 - -# ----------------------------------------------------------------------------- -# PROXY -# ----------------------------------------------------------------------------- - -# Use this section if you are running OxiCloud behind a proxy - -# Trusted Proxy IPs. Format: coma separated list of CIDR -# (default not defined = server without proxy) -# if defined and proxy's IPs match, client_ip will be defined from -# `X-Forwarded-For` / `X-Real-Ip` -#OXICLOUD_TRUST_PROXY_CIDR=192.168.0.1/32,10.1.2.0/24 - diff --git a/justfile b/justfile index f4527f55..8dbc1b11 100644 --- a/justfile +++ b/justfile @@ -64,10 +64,14 @@ front-lint: front-rules: stylelint static/css/ -# end-to-end tests +# end-to-end Playwright tests front-test: cd tests/e2e && npm test # update images snapshots front-test-update-snapshot: cd tests/e2e && npm test -- --update-snapshots + +# Hurl API functional tests (starts postgres + server, tears down after) +api-test: + bash tests/api/run.sh diff --git a/src/application/dtos/contact_dto.rs b/src/application/dtos/contact_dto.rs index bd988afd..88203436 100644 --- a/src/application/dtos/contact_dto.rs +++ b/src/application/dtos/contact_dto.rs @@ -1,8 +1,9 @@ use crate::domain::entities::contact::{Address, Contact, ContactGroup, Email, Phone}; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct EmailDto { pub email: String, pub r#type: String, @@ -19,7 +20,7 @@ impl From for EmailDto { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PhoneDto { pub number: String, pub r#type: String, @@ -36,7 +37,7 @@ impl From for PhoneDto { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AddressDto { pub street: Option, pub city: Option, @@ -61,7 +62,7 @@ impl From
for AddressDto { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContactDto { pub id: String, pub address_book_id: String, @@ -181,7 +182,7 @@ pub struct CreateContactVCardDto { pub user_id: String, // User creating the contact } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContactGroupDto { pub id: String, pub address_book_id: String, diff --git a/src/common/config.rs b/src/common/config.rs index 428ef70c..3b7d1883 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -618,6 +618,9 @@ pub struct FeaturesConfig { pub enable_trash: bool, pub enable_search: bool, pub enable_music: bool, + /// Expose other OxiCloud users as a read-only "system" address book + /// at GET /api/address-books. Set to false to hide the user directory. + pub expose_system_users: bool, } impl Default for FeaturesConfig { @@ -629,6 +632,7 @@ impl Default for FeaturesConfig { enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature enable_music: true, // Enable music feature + expose_system_users: true, // Expose OxiCloud users as address book by default } } } @@ -948,6 +952,12 @@ impl AppConfig { config.features.enable_music = val; } + if let Ok(v) = env::var("OXICLOUD_EXPOSE_SYSTEM_USERS").map(|v| v.parse::()) + && let Ok(val) = v + { + config.features.expose_system_users = val; + } + // Storage limits if let Ok(max_upload) = env::var("OXICLOUD_MAX_UPLOAD_SIZE").map(|v| v.parse::()) && let Ok(val) = max_upload diff --git a/src/infrastructure/repositories/pg/address_book_pg_repository.rs b/src/infrastructure/repositories/pg/address_book_pg_repository.rs index 2d348b37..0d2191f7 100644 --- a/src/infrastructure/repositories/pg/address_book_pg_repository.rs +++ b/src/infrastructure/repositories/pg/address_book_pg_repository.rs @@ -42,10 +42,11 @@ impl AddressBookRepository for AddressBookPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to create address book: {}", e)))?; + let owner_id: Uuid = row.get("owner_id"); Ok(AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -79,10 +80,11 @@ impl AddressBookRepository for AddressBookPgRepository { DomainError::database_error(format!("Failed to update address book: {}", e)) })?; + let owner_id: Uuid = row.get("owner_id"); Ok(AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -127,10 +129,11 @@ impl AddressBookRepository for AddressBookPgRepository { })?; let result = maybe_row.map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -164,10 +167,11 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() .map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -201,10 +205,11 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() .map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -235,10 +240,11 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() .map(|row| { + let owner_id: Uuid = row.get("owner_id"); AddressBook::from_raw( row.get("id"), row.get("name"), - row.get("owner_id"), + owner_id.to_string(), row.get("description"), row.get("color"), row.get("is_public"), @@ -317,7 +323,10 @@ impl AddressBookRepository for AddressBookPgRepository { let result = rows .into_iter() - .map(|row| (row.get("user_id"), row.get("can_write"))) + .map(|row| { + let user_id: Uuid = row.get("user_id"); + (user_id.to_string(), row.get("can_write")) + }) .collect(); Ok(result) diff --git a/src/interfaces/api/handlers/contacts_handler.rs b/src/interfaces/api/handlers/contacts_handler.rs new file mode 100644 index 00000000..99604da7 --- /dev/null +++ b/src/interfaces/api/handlers/contacts_handler.rs @@ -0,0 +1,1044 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::error; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto}; +use crate::application::dtos::contact_dto::{ + AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto, + GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto, +}; +use crate::application::dtos::user_dto::UserDto; +use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; +use crate::application::services::auth_application_service::AuthApplicationService; +use crate::domain::errors::ErrorKind; +use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; +use crate::interfaces::middleware::auth::AuthUser; + +const SYSTEM_BOOK_ID: &str = "system"; + +/// Combined state for the contacts REST API. +#[derive(Clone)] +pub struct ContactsApiState { + pub contact_service: Arc, + pub auth_service: Option>, + /// When false, the virtual "system" address book (OxiCloud users) is hidden. + pub expose_system_users: bool, +} + +/// Address book entry with `is_readonly` and `is_system` flags. +#[derive(Debug, Serialize, ToSchema)] +pub struct AddressBookResponse { + pub id: String, + pub name: String, + pub owner_id: String, + pub description: Option, + pub color: Option, + pub is_public: bool, + pub is_readonly: bool, + pub is_system: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Request body for creating an address book. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateAddressBookRequest { + pub name: String, + pub description: Option, + pub color: Option, + pub is_public: Option, +} + +/// Request body for updating an address book. +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateAddressBookRequest { + pub name: Option, + pub description: Option, + pub color: Option, + pub is_public: Option, +} + +/// Request body for creating a contact. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateContactRequest { + pub full_name: Option, + pub first_name: Option, + pub last_name: Option, + pub nickname: Option, + #[serde(default)] + pub email: Vec, + #[serde(default)] + pub phone: Vec, + #[serde(default)] + pub address: Vec, + pub organization: Option, + pub title: Option, + pub notes: Option, + pub photo_url: Option, + pub birthday: Option, + pub anniversary: Option, +} + +/// Request body for updating a contact (all fields optional). +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateContactRequest { + pub full_name: Option, + pub first_name: Option, + pub last_name: Option, + pub nickname: Option, + pub email: Option>, + pub phone: Option>, + pub address: Option>, + pub organization: Option, + pub title: Option, + pub notes: Option, + pub photo_url: Option, + pub birthday: Option, + pub anniversary: Option, +} + +/// Request body for creating or renaming a group. +#[derive(Debug, Deserialize, ToSchema)] +pub struct GroupNameRequest { + pub name: String, +} + +/// Request body for adding a contact to a group. +#[derive(Debug, Deserialize, ToSchema)] +pub struct AddMemberRequest { + pub contact_id: String, +} + +/// Query parameters for paginated listing. +#[derive(Deserialize)] +pub struct ListQuery { + #[serde(default = "default_limit")] + limit: i64, + #[serde(default)] + offset: i64, +} + +fn default_limit() -> i64 { + 100 +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn domain_err_to_response(err: crate::domain::errors::DomainError) -> Response { + let status = match err.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + ErrorKind::AccessDenied => StatusCode::FORBIDDEN, + ErrorKind::InvalidInput => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + ( + status, + Json(serde_json::json!({ "error": err.to_string() })), + ) + .into_response() +} + +fn system_book_unavailable() -> Response { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "System address book not available" })), + ) + .into_response() +} + +fn system_book_readonly() -> Response { + ( + StatusCode::METHOD_NOT_ALLOWED, + Json(serde_json::json!({ "error": "System address book is read-only" })), + ) + .into_response() +} + +/// Attach a quoted `ETag` header to an existing response. +fn with_etag(mut response: Response, etag: &str) -> Response { + if let Ok(val) = HeaderValue::from_str(&format!("\"{}\"", etag)) { + response.headers_mut().insert(header::ETAG, val); + } + response +} + +/// Return `false` when the stored etag does not satisfy the `If-Match` value. +/// Handles the `*` wildcard and quoted strings per RFC 7232. +fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool { + match if_match { + None | Some("*") => true, + Some(value) => value.trim_matches('"') == stored_etag, + } +} + +/// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts +/// inside the virtual system address book. +fn user_to_contact(user: UserDto) -> ContactDto { + ContactDto { + id: user.id.clone(), + address_book_id: SYSTEM_BOOK_ID.to_string(), + uid: format!("{}@oxicloud", user.id), + full_name: Some(user.username.clone()), + first_name: None, + last_name: None, + nickname: None, + email: vec![EmailDto { + email: user.email, + r#type: "work".to_string(), + is_primary: true, + }], + phone: vec![], + address: vec![], + organization: Some("OxiCloud".to_string()), + title: None, + notes: None, + photo_url: None, + birthday: None, + anniversary: None, + created_at: user.created_at, + updated_at: user.updated_at, + etag: user.id, + } +} + +// ── Address books ───────────────────────────────────────────────────────────── + +/// List all address books accessible to the current user (owned + shared), +/// plus the virtual read-only system book listing all OxiCloud users. +#[utoipa::path( + get, + path = "/api/address-books", + responses( + (status = 200, description = "List of address books"), + (status = 500, description = "Internal server error"), + ), + tag = "contacts" +)] +pub async fn list_address_books( + State(state): State, + auth_user: AuthUser, +) -> impl IntoResponse { + match state + .contact_service + .list_user_address_books(auth_user.id) + .await + { + Ok(books) => { + let user_id_str = auth_user.id.to_string(); + let mut response: Vec = books + .into_iter() + .map(|b| { + let is_readonly = b.owner_id != user_id_str; + AddressBookResponse { + id: b.id, + name: b.name, + owner_id: b.owner_id, + description: b.description, + color: b.color, + is_public: b.is_public, + is_readonly, + is_system: false, + created_at: b.created_at, + updated_at: b.updated_at, + } + }) + .collect(); + + if state.expose_system_users && state.auth_service.is_some() { + let now = Utc::now(); + response.push(AddressBookResponse { + id: SYSTEM_BOOK_ID.to_string(), + name: "OxiCloud Users".to_string(), + owner_id: "system".to_string(), + description: Some("All users registered on this OxiCloud instance".to_string()), + color: None, + is_public: false, + is_readonly: true, + is_system: true, + created_at: now, + updated_at: now, + }); + } + + (StatusCode::OK, Json(response)).into_response() + } + Err(err) => { + error!( + "Error listing address books for user {}: {}", + auth_user.id, err + ); + domain_err_to_response(err) + } + } +} + +/// Create a new personal address book. +#[utoipa::path( + post, + path = "/api/address-books", + responses( + (status = 201, description = "Address book created", body = AddressBookResponse), + (status = 400, description = "Invalid input"), + ), + tag = "contacts" +)] +pub async fn create_address_book( + State(state): State, + auth_user: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let dto = CreateAddressBookDto { + name: body.name, + owner_id: auth_user.id.to_string(), + description: body.description, + color: body.color, + is_public: body.is_public, + }; + match state.contact_service.create_address_book(dto).await { + Ok(book) => { + let response = AddressBookResponse { + id: book.id, + name: book.name, + owner_id: book.owner_id, + description: book.description, + color: book.color, + is_public: book.is_public, + is_readonly: false, + is_system: false, + created_at: book.created_at, + updated_at: book.updated_at, + }; + (StatusCode::CREATED, Json(response)).into_response() + } + Err(err) => { + error!("Error creating address book: {}", err); + domain_err_to_response(err) + } + } +} + +/// Update an address book's name, description, or color. +#[utoipa::path( + put, + path = "/api/address-books/{book_id}", + params(("book_id" = String, Path, description = "Address book UUID")), + responses( + (status = 200, description = "Address book updated", body = AddressBookResponse), + (status = 403, description = "Only the owner can update"), + (status = 404, description = "Address book not found"), + ), + tag = "contacts" +)] +pub async fn update_address_book( + State(state): State, + auth_user: AuthUser, + Path(book_id): Path, + Json(body): Json, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + let dto = UpdateAddressBookDto { + name: body.name, + description: body.description, + color: body.color, + is_public: body.is_public, + user_id: auth_user.id.to_string(), + }; + match state + .contact_service + .update_address_book(&book_id, dto) + .await + { + Ok(book) => { + let response = AddressBookResponse { + id: book.id, + name: book.name, + owner_id: book.owner_id.clone(), + description: book.description, + color: book.color, + is_public: book.is_public, + is_readonly: book.owner_id != auth_user.id.to_string(), + is_system: false, + created_at: book.created_at, + updated_at: book.updated_at, + }; + (StatusCode::OK, Json(response)).into_response() + } + Err(err) => { + error!("Error updating address book {}: {}", book_id, err); + domain_err_to_response(err) + } + } +} + +/// Delete an address book and all its contacts. Only the owner can do this. +#[utoipa::path( + delete, + path = "/api/address-books/{book_id}", + params(("book_id" = String, Path, description = "Address book UUID")), + responses( + (status = 204, description = "Address book deleted"), + (status = 403, description = "Only the owner can delete"), + (status = 404, description = "Address book not found"), + ), + tag = "contacts" +)] +pub async fn delete_address_book( + State(state): State, + auth_user: AuthUser, + Path(book_id): Path, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + match state + .contact_service + .delete_address_book(&book_id, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => { + error!("Error deleting address book {}: {}", book_id, err); + domain_err_to_response(err) + } + } +} + +// ── Contacts ────────────────────────────────────────────────────────────────── + +/// List contacts in an address book. +/// `book_id = "system"` returns all OxiCloud users (excluding the caller). +#[utoipa::path( + get, + path = "/api/address-books/{book_id}/contacts", + params( + ("book_id" = String, Path, description = "Address book UUID or \"system\""), + ("limit" = Option, Query, description = "Max results (default 100)"), + ("offset" = Option, Query, description = "Pagination offset (default 0)"), + ), + responses( + (status = 200, description = "List of contacts"), + (status = 403, description = "Access denied"), + (status = 404, description = "Address book not found"), + ), + tag = "contacts" +)] +pub async fn list_contacts( + State(state): State, + auth_user: AuthUser, + Path(book_id): Path, + Query(params): Query, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + if !state.expose_system_users { + return system_book_unavailable(); + } + let Some(auth_service) = &state.auth_service else { + return system_book_unavailable(); + }; + let caller_id = auth_user.id.to_string(); + match auth_service.list_users(params.limit, params.offset).await { + Ok(users) => { + let contacts: Vec = users + .into_iter() + .filter(|u| u.id != caller_id) + .map(user_to_contact) + .collect(); + (StatusCode::OK, Json(contacts)).into_response() + } + Err(err) => { + error!("Error listing OxiCloud users: {}", err); + domain_err_to_response(err) + } + } + } else { + match state + .contact_service + .list_contacts(&book_id, auth_user.id) + .await + { + Ok(contacts) => (StatusCode::OK, Json(contacts)).into_response(), + Err(err) => { + error!("Error listing contacts in book {}: {}", book_id, err); + domain_err_to_response(err) + } + } + } +} + +/// Create a new contact in an address book. +#[utoipa::path( + post, + path = "/api/address-books/{book_id}/contacts", + params(("book_id" = String, Path, description = "Address book UUID")), + responses( + (status = 201, description = "Contact created"), + (status = 400, description = "Invalid input"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Address book not found"), + ), + tag = "contacts" +)] +pub async fn create_contact( + State(state): State, + auth_user: AuthUser, + Path(book_id): Path, + Json(body): Json, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + let dto = CreateContactDto { + address_book_id: book_id.clone(), + full_name: body.full_name, + first_name: body.first_name, + last_name: body.last_name, + nickname: body.nickname, + email: body.email, + phone: body.phone, + address: body.address, + organization: body.organization, + title: body.title, + notes: body.notes, + photo_url: body.photo_url, + birthday: body.birthday, + anniversary: body.anniversary, + user_id: auth_user.id.to_string(), + }; + match state.contact_service.create_contact(dto).await { + Ok(contact) => { + let etag = contact.etag.clone(); + with_etag((StatusCode::CREATED, Json(contact)).into_response(), &etag) + } + Err(err) => { + error!("Error creating contact in book {}: {}", book_id, err); + domain_err_to_response(err) + } + } +} + +/// Get a single contact. Returns an `ETag` header for optimistic concurrency. +/// `book_id = "system"` looks up an OxiCloud user by UUID. +#[utoipa::path( + get, + path = "/api/address-books/{book_id}/contacts/{contact_id}", + params( + ("book_id" = String, Path, description = "Address book UUID or \"system\""), + ("contact_id" = String, Path, description = "Contact UUID"), + ), + responses( + (status = 200, description = "Contact details"), + (status = 403, description = "Access denied"), + (status = 404, description = "Contact not found"), + ), + tag = "contacts" +)] +pub async fn get_contact( + State(state): State, + auth_user: AuthUser, + Path((book_id, contact_id)): Path<(String, String)>, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + if !state.expose_system_users { + return system_book_unavailable(); + } + let Some(auth_service) = &state.auth_service else { + return system_book_unavailable(); + }; + let Ok(uuid) = Uuid::parse_str(&contact_id) else { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid user ID format" })), + ) + .into_response(); + }; + match auth_service.get_user_by_id(uuid).await { + Ok(user) => { + let contact = user_to_contact(user); + let etag = contact.etag.clone(); + with_etag((StatusCode::OK, Json(contact)).into_response(), &etag) + } + Err(err) => { + error!("Error fetching OxiCloud user {}: {}", contact_id, err); + domain_err_to_response(err) + } + } + } else { + match state + .contact_service + .get_contact(&contact_id, auth_user.id) + .await + { + Ok(contact) => { + let etag = contact.etag.clone(); + with_etag((StatusCode::OK, Json(contact)).into_response(), &etag) + } + Err(err) => { + error!("Error fetching contact {}: {}", contact_id, err); + domain_err_to_response(err) + } + } + } +} + +/// Update a contact. Honours `If-Match` for optimistic concurrency — returns +/// `412 Precondition Failed` when the stored ETag does not match. +#[utoipa::path( + put, + path = "/api/address-books/{book_id}/contacts/{contact_id}", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("contact_id" = String, Path, description = "Contact UUID"), + ), + responses( + (status = 200, description = "Contact updated"), + (status = 400, description = "Invalid input"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Contact not found"), + (status = 412, description = "ETag mismatch — contact was modified"), + ), + tag = "contacts" +)] +pub async fn update_contact( + State(state): State, + auth_user: AuthUser, + headers: HeaderMap, + Path((book_id, contact_id)): Path<(String, String)>, + Json(body): Json, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + + // ETag check: fetch current contact first, compare against If-Match. + let if_match = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok()); + + if if_match.is_some() { + match state + .contact_service + .get_contact(&contact_id, auth_user.id) + .await + { + Ok(current) => { + if !if_match_passes(if_match, ¤t.etag) { + return ( + StatusCode::PRECONDITION_FAILED, + Json(serde_json::json!({ + "error": "Contact was modified — fetch the latest version and retry" + })), + ) + .into_response(); + } + } + Err(err) => return domain_err_to_response(err), + } + } + + let dto = UpdateContactDto { + full_name: body.full_name, + first_name: body.first_name, + last_name: body.last_name, + nickname: body.nickname, + email: body.email, + phone: body.phone, + address: body.address, + organization: body.organization, + title: body.title, + notes: body.notes, + photo_url: body.photo_url, + birthday: body.birthday, + anniversary: body.anniversary, + user_id: auth_user.id.to_string(), + }; + + match state.contact_service.update_contact(&contact_id, dto).await { + Ok(contact) => { + let etag = contact.etag.clone(); + with_etag((StatusCode::OK, Json(contact)).into_response(), &etag) + } + Err(err) => { + error!("Error updating contact {}: {}", contact_id, err); + domain_err_to_response(err) + } + } +} + +/// Delete a contact. Optionally honours `If-Match`. +#[utoipa::path( + delete, + path = "/api/address-books/{book_id}/contacts/{contact_id}", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("contact_id" = String, Path, description = "Contact UUID"), + ), + responses( + (status = 204, description = "Contact deleted"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Contact not found"), + (status = 412, description = "ETag mismatch"), + ), + tag = "contacts" +)] +pub async fn delete_contact( + State(state): State, + auth_user: AuthUser, + headers: HeaderMap, + Path((book_id, contact_id)): Path<(String, String)>, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + + let if_match = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok()); + + if if_match.is_some() { + match state + .contact_service + .get_contact(&contact_id, auth_user.id) + .await + { + Ok(current) => { + if !if_match_passes(if_match, ¤t.etag) { + return ( + StatusCode::PRECONDITION_FAILED, + Json(serde_json::json!({ + "error": "Contact was modified — fetch the latest version and retry" + })), + ) + .into_response(); + } + } + Err(err) => return domain_err_to_response(err), + } + } + + match state + .contact_service + .delete_contact(&contact_id, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => { + error!("Error deleting contact {}: {}", contact_id, err); + domain_err_to_response(err) + } + } +} + +// ── Groups ──────────────────────────────────────────────────────────────────── + +/// List contact groups in an address book. The system book has no groups. +#[utoipa::path( + get, + path = "/api/address-books/{book_id}/groups", + params(("book_id" = String, Path, description = "Address book UUID or \"system\"")), + responses( + (status = 200, description = "List of contact groups"), + (status = 403, description = "Access denied"), + (status = 404, description = "Address book not found"), + ), + tag = "contacts" +)] +pub async fn list_groups( + State(state): State, + auth_user: AuthUser, + Path(book_id): Path, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return (StatusCode::OK, Json(Vec::::new())).into_response(); + } + match state + .contact_service + .list_groups(&book_id, auth_user.id) + .await + { + Ok(groups) => (StatusCode::OK, Json(groups)).into_response(), + Err(err) => { + error!("Error listing groups in book {}: {}", book_id, err); + domain_err_to_response(err) + } + } +} + +/// Create a contact group in an address book. +#[utoipa::path( + post, + path = "/api/address-books/{book_id}/groups", + params(("book_id" = String, Path, description = "Address book UUID")), + responses( + (status = 201, description = "Group created"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Address book not found"), + ), + tag = "contacts" +)] +pub async fn create_group( + State(state): State, + auth_user: AuthUser, + Path(book_id): Path, + Json(body): Json, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + let dto = CreateContactGroupDto { + address_book_id: book_id.clone(), + name: body.name, + user_id: auth_user.id.to_string(), + }; + match state.contact_service.create_group(dto).await { + Ok(group) => (StatusCode::CREATED, Json(group)).into_response(), + Err(err) => { + error!("Error creating group in book {}: {}", book_id, err); + domain_err_to_response(err) + } + } +} + +/// Get a single contact group by ID. +#[utoipa::path( + get, + path = "/api/address-books/{book_id}/groups/{group_id}", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("group_id" = String, Path, description = "Group UUID"), + ), + responses( + (status = 200, description = "Group details"), + (status = 403, description = "Access denied"), + (status = 404, description = "Group not found"), + ), + tag = "contacts" +)] +pub async fn get_group( + State(state): State, + auth_user: AuthUser, + Path((book_id, group_id)): Path<(String, String)>, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "System address book has no groups" })), + ) + .into_response(); + } + match state + .contact_service + .get_group(&group_id, auth_user.id) + .await + { + Ok(group) => (StatusCode::OK, Json(group)).into_response(), + Err(err) => { + error!("Error fetching group {}: {}", group_id, err); + domain_err_to_response(err) + } + } +} + +/// Rename a contact group. +#[utoipa::path( + put, + path = "/api/address-books/{book_id}/groups/{group_id}", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("group_id" = String, Path, description = "Group UUID"), + ), + responses( + (status = 200, description = "Group updated"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Group not found"), + ), + tag = "contacts" +)] +pub async fn update_group( + State(state): State, + auth_user: AuthUser, + Path((book_id, group_id)): Path<(String, String)>, + Json(body): Json, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + let dto = UpdateContactGroupDto { + name: body.name, + user_id: auth_user.id.to_string(), + }; + match state.contact_service.update_group(&group_id, dto).await { + Ok(group) => (StatusCode::OK, Json(group)).into_response(), + Err(err) => { + error!("Error updating group {}: {}", group_id, err); + domain_err_to_response(err) + } + } +} + +/// Delete a contact group. +#[utoipa::path( + delete, + path = "/api/address-books/{book_id}/groups/{group_id}", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("group_id" = String, Path, description = "Group UUID"), + ), + responses( + (status = 204, description = "Group deleted"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Group not found"), + ), + tag = "contacts" +)] +pub async fn delete_group( + State(state): State, + auth_user: AuthUser, + Path((book_id, group_id)): Path<(String, String)>, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + match state + .contact_service + .delete_group(&group_id, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => { + error!("Error deleting group {}: {}", group_id, err); + domain_err_to_response(err) + } + } +} + +// ── Group membership ────────────────────────────────────────────────────────── + +/// List contacts that belong to a group. +#[utoipa::path( + get, + path = "/api/address-books/{book_id}/groups/{group_id}/contacts", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("group_id" = String, Path, description = "Group UUID"), + ), + responses( + (status = 200, description = "Contacts in the group"), + (status = 403, description = "Access denied"), + (status = 404, description = "Group not found"), + ), + tag = "contacts" +)] +pub async fn list_contacts_in_group( + State(state): State, + auth_user: AuthUser, + Path((book_id, group_id)): Path<(String, String)>, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "System address book has no groups" })), + ) + .into_response(); + } + match state + .contact_service + .list_contacts_in_group(&group_id, auth_user.id) + .await + { + Ok(contacts) => (StatusCode::OK, Json(contacts)).into_response(), + Err(err) => { + error!("Error listing contacts in group {}: {}", group_id, err); + domain_err_to_response(err) + } + } +} + +/// Add a contact to a group. +#[utoipa::path( + post, + path = "/api/address-books/{book_id}/groups/{group_id}/contacts", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("group_id" = String, Path, description = "Group UUID"), + ), + responses( + (status = 204, description = "Contact added to group"), + (status = 400, description = "Invalid contact ID"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Group or contact not found"), + ), + tag = "contacts" +)] +pub async fn add_contact_to_group( + State(state): State, + auth_user: AuthUser, + Path((book_id, group_id)): Path<(String, String)>, + Json(body): Json, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + let dto = GroupMembershipDto { + group_id: group_id.clone(), + contact_id: body.contact_id, + }; + match state + .contact_service + .add_contact_to_group(dto, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => { + error!("Error adding contact to group {}: {}", group_id, err); + domain_err_to_response(err) + } + } +} + +/// Remove a contact from a group. +#[utoipa::path( + delete, + path = "/api/address-books/{book_id}/groups/{group_id}/contacts/{contact_id}", + params( + ("book_id" = String, Path, description = "Address book UUID"), + ("group_id" = String, Path, description = "Group UUID"), + ("contact_id" = String, Path, description = "Contact UUID"), + ), + responses( + (status = 204, description = "Contact removed from group"), + (status = 403, description = "Access denied or read-only book"), + (status = 404, description = "Group or contact not found"), + ), + tag = "contacts" +)] +pub async fn remove_contact_from_group( + State(state): State, + auth_user: AuthUser, + Path((book_id, group_id, contact_id)): Path<(String, String, String)>, +) -> impl IntoResponse { + if book_id == SYSTEM_BOOK_ID { + return system_book_readonly(); + } + let dto = GroupMembershipDto { + group_id: group_id.clone(), + contact_id, + }; + match state + .contact_service + .remove_contact_from_group(dto, auth_user.id) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => { + error!("Error removing contact from group {}: {}", group_id, err); + domain_err_to_response(err) + } + } +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 9681657e..39a0ecfd 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod batch_handler; pub mod caldav_handler; pub mod carddav_handler; pub mod chunked_upload_handler; +pub mod contacts_handler; pub mod dedup_handler; pub mod device_auth_handler; pub mod favorites_handler; diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index fd4bfbbf..c3dc610e 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -4,10 +4,14 @@ pub mod handlers; pub mod routes; pub use routes::create_api_routes; +pub use routes::create_health_routes; pub use routes::create_public_api_routes; use utoipa::OpenApi; +use crate::application::dtos::contact_dto::{ + AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto, +}; use crate::application::dtos::favorites_dto::{ BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, }; @@ -41,6 +45,10 @@ use crate::application::ports::chunked_upload_ports::{ use crate::interfaces::api::handlers::chunked_upload_handler::{ CompleteUploadResponse, CreateUploadRequest, }; +use crate::interfaces::api::handlers::contacts_handler::{ + AddMemberRequest, AddressBookResponse, CreateAddressBookRequest, CreateContactRequest, + GroupNameRequest, UpdateAddressBookRequest, UpdateContactRequest, +}; use crate::interfaces::api::handlers::dedup_handler::{ DedupUploadResponse, HashCheckResponse, StatsResponse, }; @@ -147,6 +155,24 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::music_handler::remove_share, handlers::music_handler::get_playlist_shares, handlers::music_handler::get_audio_metadata, + // Contacts / address-book handlers (free functions) + handlers::contacts_handler::list_address_books, + handlers::contacts_handler::create_address_book, + handlers::contacts_handler::update_address_book, + handlers::contacts_handler::delete_address_book, + handlers::contacts_handler::list_contacts, + handlers::contacts_handler::create_contact, + handlers::contacts_handler::get_contact, + handlers::contacts_handler::update_contact, + handlers::contacts_handler::delete_contact, + handlers::contacts_handler::list_groups, + handlers::contacts_handler::create_group, + handlers::contacts_handler::get_group, + handlers::contacts_handler::update_group, + handlers::contacts_handler::delete_group, + handlers::contacts_handler::list_contacts_in_group, + handlers::contacts_handler::add_contact_to_group, + handlers::contacts_handler::remove_contact_from_group, // Admin handlers (pub free functions) handlers::admin_handler::get_dashboard_stats, handlers::admin_handler::list_users, @@ -231,6 +257,19 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; HashCheckResponse, DedupUploadResponse, StatsResponse, + // Contacts / address-book schemas + AddressBookResponse, + CreateAddressBookRequest, + UpdateAddressBookRequest, + ContactDto, + ContactGroupDto, + EmailDto, + PhoneDto, + AddressDto, + CreateContactRequest, + UpdateContactRequest, + GroupNameRequest, + AddMemberRequest, ) ), tags( @@ -247,6 +286,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; (name = "dedup", description = "Content deduplication endpoints"), (name = "batch", description = "Batch operation endpoints"), (name = "playlists", description = "Music playlist endpoints"), + (name = "contacts", description = "Address books, contacts, and groups endpoints"), (name = "admin", description = "Admin management endpoints"), ), info( diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index d729b114..17de42e3 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -2,8 +2,9 @@ use crate::application::services::batch_operations::BatchOperationService; use crate::common::di::AppState; use axum::{ Router, - extract::DefaultBodyLimit, - response::Json as AxumJson, + extract::{DefaultBodyLimit, State}, + http::StatusCode, + response::{IntoResponse, Json as AxumJson}, routing::{delete, get, post, put}, }; use serde_json::json; @@ -11,6 +12,31 @@ use std::sync::Arc; use tower_http::{compression::CompressionLayer, trace::TraceLayer}; use utoipa::OpenApi; +/// Liveness probe — returns 200 if the process is running, no DB check. +async fn health() -> impl IntoResponse { + (StatusCode::OK, AxumJson(json!({"status": "ok"}))) +} + +/// Readiness probe — returns 200 if the DB pool can serve queries, 503 otherwise. +async fn ready(State(state): State>) -> impl IntoResponse { + match &state.db_pool { + Some(pool) => match sqlx::query("SELECT 1").execute(pool.as_ref()).await { + Ok(_) => ( + StatusCode::OK, + AxumJson(json!({"status": "ok", "db": "ok"})), + ), + Err(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + AxumJson(json!({"status": "error", "db": "error"})), + ), + }, + None => ( + StatusCode::SERVICE_UNAVAILABLE, + AxumJson(json!({"status": "error", "db": "not configured"})), + ), + } +} + /// Returns the application version from Cargo.toml (compile-time constant) async fn get_version() -> AxumJson { AxumJson(json!({ @@ -45,6 +71,18 @@ use crate::interfaces::api::handlers::search_handler::{ }; use crate::interfaces::api::handlers::trash_handler; +/// Creates root-level health check routes — mounted directly at `/`, not under `/api/`. +/// (follow docker/kubernetes best practices) +/// +/// - `GET /health` — liveness probe, no DB check, always 200 if process is up. +/// - `GET /ready` — readiness probe, pings DB pool, returns 503 if unreachable. +pub fn create_health_routes(app_state: &Arc) -> Router> { + Router::new() + .route("/health", get(health)) + .route("/ready", get(ready)) + .with_state(app_state.clone()) +} + /// Creates public API routes that should NOT require authentication. pub fn create_public_api_routes(app_state: &Arc) -> Router> { let share_service = app_state.share_service.clone(); @@ -392,6 +430,68 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { tracing::info!("Music routes initialized"); } + // REST browse API for CardDAV contacts, groups, and OxiCloud users. + // Write operations and protocol sync remain on the /carddav endpoint. + if let Some(contact_service) = app_state.contact_use_case.clone() { + use crate::interfaces::api::handlers::contacts_handler::{self, ContactsApiState}; + + let auth_svc = app_state + .auth_service + .as_ref() + .map(|s| s.auth_application_service.clone()); + + let contacts_state = ContactsApiState { + contact_service, + auth_service: auth_svc, + expose_system_users: app_state.core.config.features.expose_system_users, + }; + + let contacts_router = Router::new() + .route( + "/", + get(contacts_handler::list_address_books) + .post(contacts_handler::create_address_book), + ) + .route( + "/{book_id}", + put(contacts_handler::update_address_book) + .delete(contacts_handler::delete_address_book), + ) + .route( + "/{book_id}/contacts", + get(contacts_handler::list_contacts).post(contacts_handler::create_contact), + ) + .route( + "/{book_id}/contacts/{contact_id}", + get(contacts_handler::get_contact) + .put(contacts_handler::update_contact) + .delete(contacts_handler::delete_contact), + ) + .route( + "/{book_id}/groups", + get(contacts_handler::list_groups).post(contacts_handler::create_group), + ) + .route( + "/{book_id}/groups/{group_id}", + get(contacts_handler::get_group) + .put(contacts_handler::update_group) + .delete(contacts_handler::delete_group), + ) + .route( + "/{book_id}/groups/{group_id}/contacts", + get(contacts_handler::list_contacts_in_group) + .post(contacts_handler::add_contact_to_group), + ) + .route( + "/{book_id}/groups/{group_id}/contacts/{contact_id}", + delete(contacts_handler::remove_contact_from_group), + ) + .with_state(contacts_state); + + router = router.nest("/address-books", contacts_router); + tracing::info!("Contacts REST API routes initialized"); + } + // NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs // for client compatibility, NOT under /api. diff --git a/src/interfaces/mod.rs b/src/interfaces/mod.rs index 5fa958f5..1b7e6d78 100644 --- a/src/interfaces/mod.rs +++ b/src/interfaces/mod.rs @@ -5,4 +5,5 @@ pub mod nextcloud; pub mod web; pub use api::create_api_routes; +pub use api::create_health_routes; pub use api::create_public_api_routes; diff --git a/src/main.rs b/src/main.rs index e0ffeb4a..bfef6ae8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,9 @@ use oxicloud::interfaces; use common::di::AppServiceFactory; use infrastructure::db::create_database_pools; -use interfaces::{create_api_routes, create_public_api_routes, web::create_web_routes}; +use interfaces::{ + create_api_routes, create_health_routes, create_public_api_routes, web::create_web_routes, +}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -115,6 +117,7 @@ async fn main() -> Result<(), Box> { // Build application router let api_routes = create_api_routes(&app_state); let public_api_routes = create_public_api_routes(&app_state); + let health_routes = create_health_routes(&app_state); let web_routes = create_web_routes(); let mut app; @@ -319,6 +322,8 @@ async fn main() -> Result<(), Box> { )); app = Router::new() + // Health / readiness probes — no auth, mounted at root + .merge(health_routes) // Rate-limited auth endpoints (login, register, refresh) .nest("/api/auth", auth_login) .nest("/api/auth", auth_register) @@ -375,6 +380,8 @@ async fn main() -> Result<(), Box> { // Auth disabled — no middleware applied tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible"); app = Router::new() + // Health / readiness probes — no auth, mounted at root + .merge(health_routes) .nest("/api", public_api_routes) .nest("/api", api_routes) // RFC 6764 well-known discovery (just redirects) diff --git a/static/index.html b/static/index.html index fdfea9b0..e2ad42c0 100644 --- a/static/index.html +++ b/static/index.html @@ -285,7 +285,7 @@ Clean Architecture