Merge pull request #359 from EdouardVanbelle/feat/contacts
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# IMPORTANT: using Swatinem/rust-cache@v2 : reuse same cache as playwright.yml (minimize non necessary new compilation)
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -42,8 +40,8 @@ jobs:
|
|||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
|
|
||||||
frontend-linter:
|
frontend-check:
|
||||||
name: Frontend — CSS and JS checks (format, lint, rules, etc)
|
name: Frontend — CSS and JS checks (format, lint, rules)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -98,7 +96,7 @@ jobs:
|
|||||||
- run: cargo clippy --all-targets --all-features -- -D warnings
|
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
rust-test:
|
rust-test:
|
||||||
name: Tests
|
name: Server Unit and Functionnal Tests
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.backend == 'true'
|
if: needs.changes.outputs.backend == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -143,11 +141,97 @@ jobs:
|
|||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build Check
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [frontend-linter, rust-fmt, rust-clippy, rust-test]
|
needs: [rust-fmt, rust-clippy, rust-test]
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cargo build --release
|
- 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
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main", "dev" ]
|
branches: [ "main", "dev" ]
|
||||||
|
|
||||||
concurrency:
|
#concurrency:
|
||||||
group: rust-build-${{ github.ref }}
|
# group: rust-build-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
# cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
|
|||||||
@@ -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
|
|
||||||
+2
-2
@@ -67,10 +67,10 @@ WORKDIR /app
|
|||||||
# Expose application port
|
# Expose application port
|
||||||
EXPOSE 8086
|
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.
|
# Docker / Compose / Swarm will mark the container unhealthy after 3 failures.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
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.
|
# Entrypoint fixes volume permissions then drops to oxicloud user.
|
||||||
# The container starts as root so it can chown mounted volumes,
|
# The container starts as root so it can chown mounted volumes,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- storage_data:/app/storage
|
- 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:
|
networks:
|
||||||
oxicloud:
|
oxicloud:
|
||||||
|
|||||||
+117
-17
@@ -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_STATIC_PATH` | `./static` | Static files directory |
|
||||||
| `OXICLOUD_SERVER_PORT` | `8086` | Server port |
|
| `OXICLOUD_SERVER_PORT` | `8086` | Server port |
|
||||||
| `OXICLOUD_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
| `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
|
## Database
|
||||||
|
|
||||||
@@ -32,9 +33,25 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `OXICLOUD_JWT_SECRET` | (random) | JWT signing secret |
|
| `OXICLOUD_JWT_SECRET` | (auto-generated) | JWT signing secret; auto-persisted to `<STORAGE_PATH>/.jwt_secret` if unset |
|
||||||
| `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (seconds) |
|
| `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (1 hour) |
|
||||||
| `OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS` | `2592000` | Refresh token lifetime (seconds) |
|
| `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
|
## 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_USER_STORAGE_QUOTAS` | `false` | Per-user storage quotas |
|
||||||
| `OXICLOUD_ENABLE_FILE_SHARING` | `true` | File/folder sharing |
|
| `OXICLOUD_ENABLE_FILE_SHARING` | `true` | File/folder sharing |
|
||||||
| `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin |
|
| `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
|
## OIDC / SSO
|
||||||
|
|
||||||
@@ -56,13 +136,13 @@ See the [OIDC configuration guide](/config/oidc) for details.
|
|||||||
| `OXICLOUD_OIDC_ISSUER_URL` | — | OIDC issuer URL |
|
| `OXICLOUD_OIDC_ISSUER_URL` | — | OIDC issuer URL |
|
||||||
| `OXICLOUD_OIDC_CLIENT_ID` | — | Client ID |
|
| `OXICLOUD_OIDC_CLIENT_ID` | — | Client ID |
|
||||||
| `OXICLOUD_OIDC_CLIENT_SECRET` | — | Client secret |
|
| `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_SCOPES` | `openid profile email` | Requested scopes |
|
||||||
| `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Frontend URL |
|
| `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 |
|
| `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first SSO login (JIT provisioning) |
|
||||||
| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Groups that grant admin role |
|
| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Comma-separated OIDC groups that grant admin role |
|
||||||
| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password form when OIDC enabled |
|
| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password form when OIDC is active |
|
||||||
| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider |
|
| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider shown in UI |
|
||||||
|
|
||||||
## WOPI (Office Editing)
|
## WOPI (Office Editing)
|
||||||
|
|
||||||
@@ -73,21 +153,40 @@ See the [WOPI configuration guide](/config/wopi) for details.
|
|||||||
| `OXICLOUD_WOPI_ENABLED` | `false` | Enable WOPI |
|
| `OXICLOUD_WOPI_ENABLED` | `false` | Enable WOPI |
|
||||||
| `OXICLOUD_WOPI_DISCOVERY_URL` | — | Collabora/OnlyOffice discovery URL |
|
| `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_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_SECRET` | (JWT secret) | WOPI token signing key |
|
||||||
| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Token lifetime |
|
| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Token lifetime (24 hours) |
|
||||||
| `OXICLOUD_WOPI_LOCK_TTL_SECS` | `1800` | Lock expiration |
|
| `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.
|
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
|
## Allocator Tuning
|
||||||
|
|
||||||
These variables are read directly by **mimalloc**, not by OxiCloud's config parser.
|
These variables are read directly by **mimalloc**, not by OxiCloud's config parser.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MIMALLOC_PURGE_DELAY` | `0` | Delay in ms before freed memory is returned to the OS |
|
| `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 or disable large OS pages for allocations |
|
| `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)
|
## 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 |
|
| Streaming chunk size | 1 MB |
|
||||||
| Max parallel chunks | 8 |
|
| Max parallel chunks | 8 |
|
||||||
| Trash retention | 30 days |
|
| Trash retention | 30 days |
|
||||||
| Argon2id memory cost | 64 MB |
|
| Argon2id memory cost | 64 MiB |
|
||||||
| Argon2id time cost | 3 iterations |
|
| Argon2id time cost | 3 iterations |
|
||||||
|
| Nextcloud Login Flow v2 TTL | 600 s |
|
||||||
|
|||||||
+151
-17
@@ -30,6 +30,9 @@ OXICLOUD_SERVER_HOST=127.0.0.1
|
|||||||
# Example: https://cloud.example.com
|
# Example: https://cloud.example.com
|
||||||
#OXICLOUD_BASE_URL=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
|
# DATABASE CONFIGURATION
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -48,8 +51,7 @@ OXICLOUD_DB_CONNECTION_STRING=postgres://postgres:postgres@postgres/oxicloud
|
|||||||
|
|
||||||
# Maximum connections for the maintenance pool (background/batch tasks).
|
# Maximum connections for the maintenance pool (background/batch tasks).
|
||||||
# This pool is isolated from user requests, preventing background operations
|
# This pool is isolated from user requests, preventing background operations
|
||||||
# (verify_integrity, garbage_collect, storage recalculation) from starving
|
# from starving interactive traffic. Default: 5
|
||||||
# interactive traffic. Default: 5
|
|
||||||
#OXICLOUD_DB_MAINTENANCE_MAX_CONNECTIONS=5
|
#OXICLOUD_DB_MAINTENANCE_MAX_CONNECTIONS=5
|
||||||
|
|
||||||
# Minimum connections for the maintenance pool. Default: 1
|
# 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)
|
# Access token lifetime in seconds (default: 3600 = 1 hour)
|
||||||
#OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS=3600
|
#OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS=3600
|
||||||
|
|
||||||
# Refresh token lifetime in seconds (default: 2592000 = 30 days)
|
# Refresh token lifetime in seconds (default: 604800 = 7 days)
|
||||||
#OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS=2592000
|
# 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
|
# FEATURE FLAGS
|
||||||
@@ -96,6 +133,85 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
|||||||
# Enable search functionality (default: true)
|
# Enable search functionality (default: true)
|
||||||
#OXICLOUD_ENABLE_SEARCH=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
|
# OPENID CONNECT (OIDC) / SSO CONFIGURATION
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -170,6 +286,37 @@ OXICLOUD_WOPI_ENABLED=false
|
|||||||
# WOPI lock expiration in seconds (default: 1800 = 30 minutes)
|
# WOPI lock expiration in seconds (default: 1800 = 30 minutes)
|
||||||
#OXICLOUD_WOPI_LOCK_TTL_SECS=1800
|
#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)
|
# 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
|
# When enabled with Linux Transparent Huge Pages (THP), partially-used 2 MiB
|
||||||
# pages inflate the reported RSS by up to 20-30 MiB.
|
# pages inflate the reported RSS by up to 20-30 MiB.
|
||||||
MIMALLOC_ALLOW_LARGE_OS_PAGES=0
|
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
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,10 +64,14 @@ front-lint:
|
|||||||
front-rules:
|
front-rules:
|
||||||
stylelint static/css/
|
stylelint static/css/
|
||||||
|
|
||||||
# end-to-end tests
|
# end-to-end Playwright tests
|
||||||
front-test:
|
front-test:
|
||||||
cd tests/e2e && npm test
|
cd tests/e2e && npm test
|
||||||
|
|
||||||
# update images snapshots
|
# update images snapshots
|
||||||
front-test-update-snapshot:
|
front-test-update-snapshot:
|
||||||
cd tests/e2e && npm test -- --update-snapshots
|
cd tests/e2e && npm test -- --update-snapshots
|
||||||
|
|
||||||
|
# Hurl API functional tests (starts postgres + server, tears down after)
|
||||||
|
api-test:
|
||||||
|
bash tests/api/run.sh
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::domain::entities::contact::{Address, Contact, ContactGroup, Email, Phone};
|
use crate::domain::entities::contact::{Address, Contact, ContactGroup, Email, Phone};
|
||||||
use chrono::{DateTime, NaiveDate, Utc};
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct EmailDto {
|
pub struct EmailDto {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub r#type: String,
|
pub r#type: String,
|
||||||
@@ -19,7 +20,7 @@ impl From<Email> for EmailDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct PhoneDto {
|
pub struct PhoneDto {
|
||||||
pub number: String,
|
pub number: String,
|
||||||
pub r#type: String,
|
pub r#type: String,
|
||||||
@@ -36,7 +37,7 @@ impl From<Phone> for PhoneDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct AddressDto {
|
pub struct AddressDto {
|
||||||
pub street: Option<String>,
|
pub street: Option<String>,
|
||||||
pub city: Option<String>,
|
pub city: Option<String>,
|
||||||
@@ -61,7 +62,7 @@ impl From<Address> for AddressDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ContactDto {
|
pub struct ContactDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub address_book_id: String,
|
pub address_book_id: String,
|
||||||
@@ -181,7 +182,7 @@ pub struct CreateContactVCardDto {
|
|||||||
pub user_id: String, // User creating the contact
|
pub user_id: String, // User creating the contact
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ContactGroupDto {
|
pub struct ContactGroupDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub address_book_id: String,
|
pub address_book_id: String,
|
||||||
|
|||||||
@@ -618,6 +618,9 @@ pub struct FeaturesConfig {
|
|||||||
pub enable_trash: bool,
|
pub enable_trash: bool,
|
||||||
pub enable_search: bool,
|
pub enable_search: bool,
|
||||||
pub enable_music: 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 {
|
impl Default for FeaturesConfig {
|
||||||
@@ -629,6 +632,7 @@ impl Default for FeaturesConfig {
|
|||||||
enable_trash: true, // Enable trash feature
|
enable_trash: true, // Enable trash feature
|
||||||
enable_search: true, // Enable search feature
|
enable_search: true, // Enable search feature
|
||||||
enable_music: true, // Enable music 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;
|
config.features.enable_music = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_EXPOSE_SYSTEM_USERS").map(|v| v.parse::<bool>())
|
||||||
|
&& let Ok(val) = v
|
||||||
|
{
|
||||||
|
config.features.expose_system_users = val;
|
||||||
|
}
|
||||||
|
|
||||||
// Storage limits
|
// Storage limits
|
||||||
if let Ok(max_upload) = env::var("OXICLOUD_MAX_UPLOAD_SIZE").map(|v| v.parse::<usize>())
|
if let Ok(max_upload) = env::var("OXICLOUD_MAX_UPLOAD_SIZE").map(|v| v.parse::<usize>())
|
||||||
&& let Ok(val) = max_upload
|
&& let Ok(val) = max_upload
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::database_error(format!("Failed to create address book: {}", e)))?;
|
.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(
|
Ok(AddressBook::from_raw(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("name"),
|
row.get("name"),
|
||||||
row.get("owner_id"),
|
owner_id.to_string(),
|
||||||
row.get("description"),
|
row.get("description"),
|
||||||
row.get("color"),
|
row.get("color"),
|
||||||
row.get("is_public"),
|
row.get("is_public"),
|
||||||
@@ -79,10 +80,11 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
DomainError::database_error(format!("Failed to update address book: {}", e))
|
DomainError::database_error(format!("Failed to update address book: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let owner_id: Uuid = row.get("owner_id");
|
||||||
Ok(AddressBook::from_raw(
|
Ok(AddressBook::from_raw(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("name"),
|
row.get("name"),
|
||||||
row.get("owner_id"),
|
owner_id.to_string(),
|
||||||
row.get("description"),
|
row.get("description"),
|
||||||
row.get("color"),
|
row.get("color"),
|
||||||
row.get("is_public"),
|
row.get("is_public"),
|
||||||
@@ -127,10 +129,11 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let result = maybe_row.map(|row| {
|
let result = maybe_row.map(|row| {
|
||||||
|
let owner_id: Uuid = row.get("owner_id");
|
||||||
AddressBook::from_raw(
|
AddressBook::from_raw(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("name"),
|
row.get("name"),
|
||||||
row.get("owner_id"),
|
owner_id.to_string(),
|
||||||
row.get("description"),
|
row.get("description"),
|
||||||
row.get("color"),
|
row.get("color"),
|
||||||
row.get("is_public"),
|
row.get("is_public"),
|
||||||
@@ -164,10 +167,11 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
let result = rows
|
let result = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
|
let owner_id: Uuid = row.get("owner_id");
|
||||||
AddressBook::from_raw(
|
AddressBook::from_raw(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("name"),
|
row.get("name"),
|
||||||
row.get("owner_id"),
|
owner_id.to_string(),
|
||||||
row.get("description"),
|
row.get("description"),
|
||||||
row.get("color"),
|
row.get("color"),
|
||||||
row.get("is_public"),
|
row.get("is_public"),
|
||||||
@@ -201,10 +205,11 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
let result = rows
|
let result = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
|
let owner_id: Uuid = row.get("owner_id");
|
||||||
AddressBook::from_raw(
|
AddressBook::from_raw(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("name"),
|
row.get("name"),
|
||||||
row.get("owner_id"),
|
owner_id.to_string(),
|
||||||
row.get("description"),
|
row.get("description"),
|
||||||
row.get("color"),
|
row.get("color"),
|
||||||
row.get("is_public"),
|
row.get("is_public"),
|
||||||
@@ -235,10 +240,11 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
let result = rows
|
let result = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
|
let owner_id: Uuid = row.get("owner_id");
|
||||||
AddressBook::from_raw(
|
AddressBook::from_raw(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("name"),
|
row.get("name"),
|
||||||
row.get("owner_id"),
|
owner_id.to_string(),
|
||||||
row.get("description"),
|
row.get("description"),
|
||||||
row.get("color"),
|
row.get("color"),
|
||||||
row.get("is_public"),
|
row.get("is_public"),
|
||||||
@@ -317,7 +323,10 @@ impl AddressBookRepository for AddressBookPgRepository {
|
|||||||
|
|
||||||
let result = rows
|
let result = rows
|
||||||
.into_iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ pub mod batch_handler;
|
|||||||
pub mod caldav_handler;
|
pub mod caldav_handler;
|
||||||
pub mod carddav_handler;
|
pub mod carddav_handler;
|
||||||
pub mod chunked_upload_handler;
|
pub mod chunked_upload_handler;
|
||||||
|
pub mod contacts_handler;
|
||||||
pub mod dedup_handler;
|
pub mod dedup_handler;
|
||||||
pub mod device_auth_handler;
|
pub mod device_auth_handler;
|
||||||
pub mod favorites_handler;
|
pub mod favorites_handler;
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ pub mod handlers;
|
|||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
pub use routes::create_api_routes;
|
pub use routes::create_api_routes;
|
||||||
|
pub use routes::create_health_routes;
|
||||||
pub use routes::create_public_api_routes;
|
pub use routes::create_public_api_routes;
|
||||||
|
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use crate::application::dtos::contact_dto::{
|
||||||
|
AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto,
|
||||||
|
};
|
||||||
use crate::application::dtos::favorites_dto::{
|
use crate::application::dtos::favorites_dto::{
|
||||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
||||||
};
|
};
|
||||||
@@ -41,6 +45,10 @@ use crate::application::ports::chunked_upload_ports::{
|
|||||||
use crate::interfaces::api::handlers::chunked_upload_handler::{
|
use crate::interfaces::api::handlers::chunked_upload_handler::{
|
||||||
CompleteUploadResponse, CreateUploadRequest,
|
CompleteUploadResponse, CreateUploadRequest,
|
||||||
};
|
};
|
||||||
|
use crate::interfaces::api::handlers::contacts_handler::{
|
||||||
|
AddMemberRequest, AddressBookResponse, CreateAddressBookRequest, CreateContactRequest,
|
||||||
|
GroupNameRequest, UpdateAddressBookRequest, UpdateContactRequest,
|
||||||
|
};
|
||||||
use crate::interfaces::api::handlers::dedup_handler::{
|
use crate::interfaces::api::handlers::dedup_handler::{
|
||||||
DedupUploadResponse, HashCheckResponse, StatsResponse,
|
DedupUploadResponse, HashCheckResponse, StatsResponse,
|
||||||
};
|
};
|
||||||
@@ -147,6 +155,24 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
handlers::music_handler::remove_share,
|
handlers::music_handler::remove_share,
|
||||||
handlers::music_handler::get_playlist_shares,
|
handlers::music_handler::get_playlist_shares,
|
||||||
handlers::music_handler::get_audio_metadata,
|
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)
|
// Admin handlers (pub free functions)
|
||||||
handlers::admin_handler::get_dashboard_stats,
|
handlers::admin_handler::get_dashboard_stats,
|
||||||
handlers::admin_handler::list_users,
|
handlers::admin_handler::list_users,
|
||||||
@@ -231,6 +257,19 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
HashCheckResponse,
|
HashCheckResponse,
|
||||||
DedupUploadResponse,
|
DedupUploadResponse,
|
||||||
StatsResponse,
|
StatsResponse,
|
||||||
|
// Contacts / address-book schemas
|
||||||
|
AddressBookResponse,
|
||||||
|
CreateAddressBookRequest,
|
||||||
|
UpdateAddressBookRequest,
|
||||||
|
ContactDto,
|
||||||
|
ContactGroupDto,
|
||||||
|
EmailDto,
|
||||||
|
PhoneDto,
|
||||||
|
AddressDto,
|
||||||
|
CreateContactRequest,
|
||||||
|
UpdateContactRequest,
|
||||||
|
GroupNameRequest,
|
||||||
|
AddMemberRequest,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -247,6 +286,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
(name = "dedup", description = "Content deduplication endpoints"),
|
(name = "dedup", description = "Content deduplication endpoints"),
|
||||||
(name = "batch", description = "Batch operation endpoints"),
|
(name = "batch", description = "Batch operation endpoints"),
|
||||||
(name = "playlists", description = "Music playlist endpoints"),
|
(name = "playlists", description = "Music playlist endpoints"),
|
||||||
|
(name = "contacts", description = "Address books, contacts, and groups endpoints"),
|
||||||
(name = "admin", description = "Admin management endpoints"),
|
(name = "admin", description = "Admin management endpoints"),
|
||||||
),
|
),
|
||||||
info(
|
info(
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ use crate::application::services::batch_operations::BatchOperationService;
|
|||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::DefaultBodyLimit,
|
extract::{DefaultBodyLimit, State},
|
||||||
response::Json as AxumJson,
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Json as AxumJson},
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -11,6 +12,31 @@ use std::sync::Arc;
|
|||||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||||
use utoipa::OpenApi;
|
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<Arc<AppState>>) -> 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)
|
/// Returns the application version from Cargo.toml (compile-time constant)
|
||||||
async fn get_version() -> AxumJson<serde_json::Value> {
|
async fn get_version() -> AxumJson<serde_json::Value> {
|
||||||
AxumJson(json!({
|
AxumJson(json!({
|
||||||
@@ -45,6 +71,18 @@ use crate::interfaces::api::handlers::search_handler::{
|
|||||||
};
|
};
|
||||||
use crate::interfaces::api::handlers::trash_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<AppState>) -> Router<Arc<AppState>> {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/ready", get(ready))
|
||||||
|
.with_state(app_state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates public API routes that should NOT require authentication.
|
/// Creates public API routes that should NOT require authentication.
|
||||||
pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||||
let share_service = app_state.share_service.clone();
|
let share_service = app_state.share_service.clone();
|
||||||
@@ -392,6 +430,68 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
tracing::info!("Music routes initialized");
|
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
|
// NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs
|
||||||
// for client compatibility, NOT under /api.
|
// for client compatibility, NOT under /api.
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ pub mod nextcloud;
|
|||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
pub use api::create_api_routes;
|
pub use api::create_api_routes;
|
||||||
|
pub use api::create_health_routes;
|
||||||
pub use api::create_public_api_routes;
|
pub use api::create_public_api_routes;
|
||||||
|
|||||||
+8
-1
@@ -50,7 +50,9 @@ use oxicloud::interfaces;
|
|||||||
|
|
||||||
use common::di::AppServiceFactory;
|
use common::di::AppServiceFactory;
|
||||||
use infrastructure::db::create_database_pools;
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -115,6 +117,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Build application router
|
// Build application router
|
||||||
let api_routes = create_api_routes(&app_state);
|
let api_routes = create_api_routes(&app_state);
|
||||||
let public_api_routes = create_public_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 web_routes = create_web_routes();
|
||||||
|
|
||||||
let mut app;
|
let mut app;
|
||||||
@@ -319,6 +322,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
));
|
));
|
||||||
|
|
||||||
app = Router::new()
|
app = Router::new()
|
||||||
|
// Health / readiness probes — no auth, mounted at root
|
||||||
|
.merge(health_routes)
|
||||||
// Rate-limited auth endpoints (login, register, refresh)
|
// Rate-limited auth endpoints (login, register, refresh)
|
||||||
.nest("/api/auth", auth_login)
|
.nest("/api/auth", auth_login)
|
||||||
.nest("/api/auth", auth_register)
|
.nest("/api/auth", auth_register)
|
||||||
@@ -375,6 +380,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Auth disabled — no middleware applied
|
// Auth disabled — no middleware applied
|
||||||
tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible");
|
tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible");
|
||||||
app = Router::new()
|
app = Router::new()
|
||||||
|
// Health / readiness probes — no auth, mounted at root
|
||||||
|
.merge(health_routes)
|
||||||
.nest("/api", public_api_routes)
|
.nest("/api", public_api_routes)
|
||||||
.nest("/api", api_routes)
|
.nest("/api", api_routes)
|
||||||
// RFC 6764 well-known discovery (just redirects)
|
// RFC 6764 well-known discovery (just redirects)
|
||||||
|
|||||||
+1
-1
@@ -285,7 +285,7 @@
|
|||||||
<span class="about-tech-badge">Clean Architecture</span>
|
<span class="about-tech-badge">Clean Architecture</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-links">
|
<div class="about-links">
|
||||||
<a href="https://github.com" class="about-link" target="_blank" rel="noopener">
|
<a href="https://github.com/AtalayaLabs/OxiCloud/" class="about-link" target="_blank" rel="noopener">
|
||||||
<i class="fab fa-github"></i> GitHub
|
<i class="fab fa-github"></i> GitHub
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="about-link" id="about-license-link">
|
<a href="#" class="about-link" id="about-license-link">
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# API functional tests
|
||||||
|
|
||||||
|
Tests are written using [Hurl](https://hurl.dev) — a plain-text, CLI-first HTTP testing tool.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Hurl](https://hurl.dev/docs/installation.html) ≥ 4.0
|
||||||
|
Install: `cargo install hurl` or via your package manager
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `test.env` to match your local instance. This file is the single source of
|
||||||
|
truth: `run.sh` sources it for shell variables and passes it to Hurl as
|
||||||
|
`--variables-file`.
|
||||||
|
|
||||||
|
```
|
||||||
|
base_url=http://localhost:8087
|
||||||
|
username=admin
|
||||||
|
email=admin@example.com
|
||||||
|
password=TestPassword1!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First-time setup (run once on a fresh instance)
|
||||||
|
hurl --variables-file tests/api/test.env --test tests/api/setup.hurl
|
||||||
|
|
||||||
|
# Contacts CRUD scenario
|
||||||
|
hurl --variables-file tests/api/test.env --test tests/api/contacts.hurl
|
||||||
|
|
||||||
|
# All scenarios at once
|
||||||
|
hurl --variables-file tests/api/test.env --test tests/api/setup.hurl tests/api/contacts.hurl
|
||||||
|
|
||||||
|
# With full request/response output
|
||||||
|
hurl --variables-file tests/api/test.env --test --verbose tests/api/contacts.hurl
|
||||||
|
|
||||||
|
# Generate an HTML report
|
||||||
|
hurl --variables-file tests/api/test.env --test --report-html /tmp/hurl-report tests/api/contacts.hurl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|---|---|
|
||||||
|
| `setup.hurl` | One-time admin account creation; also asserts the endpoint is locked afterwards |
|
||||||
|
| `contacts.hurl` | Full contacts CRUD scenario (13 steps, see below) |
|
||||||
|
| `test.env` | Variables: `base_url`, `username`, `email`, `password` — used by both Hurl and `run.sh` |
|
||||||
|
|
||||||
|
## Scenario: `contacts.hurl`
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Login – capture JWT token |
|
||||||
|
| 2 | List address books – assert system book is present and read-only |
|
||||||
|
| 3 | Create personal address book – capture `book_id` |
|
||||||
|
| 4 | List contacts in new book – assert empty |
|
||||||
|
| 5 | Create contact John Doe – capture `contact_id` |
|
||||||
|
| 6 | List contacts – assert exactly 1 result with John Doe's id |
|
||||||
|
| 7 | Get John Doe – assert all fields, capture `ETag` |
|
||||||
|
| 8 | Update John Doe (nickname, org, notes) with `If-Match` – assert new values, capture refreshed `ETag` |
|
||||||
|
| 9 | Delete John Doe with `If-Match` |
|
||||||
|
| 10 | List contacts – assert empty again |
|
||||||
|
| 11 | Delete personal address book |
|
||||||
|
| 12 | List address books – assert `book_id` no longer present |
|
||||||
|
| 13 | List system address book – assert non-empty collection of OxiCloud users |
|
||||||
|
|
||||||
|
## Legacy bash tests
|
||||||
|
|
||||||
|
`test.sh` and `common.sh` are the original curl/bash scripts kept for reference.
|
||||||
|
Run them with `bash tests/api/test.sh` from the repo root (requires `jq`).
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud – Contacts API end-to-end scenario
|
||||||
|
# =============================================================
|
||||||
|
# Run:
|
||||||
|
# hurl --variables-file tests/api/hurl.vars --test tests/api/contacts.hurl
|
||||||
|
#
|
||||||
|
# Variables required (see hurl.vars):
|
||||||
|
# base_url – e.g. http://localhost:8087
|
||||||
|
# username – OxiCloud username
|
||||||
|
# password – OxiCloud password
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 – Login and capture the JWT token
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "{{username}}",
|
||||||
|
"password": "{{password}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
token: jsonpath "$.access_token"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.access_token" isString
|
||||||
|
jsonpath "$.token_type" == "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 – List address books (at least the system book)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" isCollection
|
||||||
|
# System address book must always be present
|
||||||
|
jsonpath "$[?(@.id == 'system')].is_system" == true
|
||||||
|
jsonpath "$[?(@.id == 'system')].is_readonly" == true
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 – Create a personal address book
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/address-books
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"name": "Personal",
|
||||||
|
"description": "Personal address book created by Hurl tests",
|
||||||
|
"is_public": false
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
book_id: jsonpath "$.id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" isString
|
||||||
|
jsonpath "$.name" == "Personal"
|
||||||
|
jsonpath "$.description" == "Personal address book created by Hurl tests"
|
||||||
|
jsonpath "$.is_public" == false
|
||||||
|
jsonpath "$.is_system" == false
|
||||||
|
jsonpath "$.is_readonly" == false
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 – List contacts in the new book – must be empty
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books/{{book_id}}/contacts
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" isCollection
|
||||||
|
jsonpath "$" count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 – Create contact John Doe
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/address-books/{{book_id}}/contacts
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"email": [
|
||||||
|
{
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"type": "work",
|
||||||
|
"is_primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"phone": [
|
||||||
|
{
|
||||||
|
"number": "+1-555-0100",
|
||||||
|
"type": "mobile",
|
||||||
|
"is_primary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
contact_id: jsonpath "$.id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" isString
|
||||||
|
jsonpath "$.address_book_id" == {{book_id}}
|
||||||
|
jsonpath "$.first_name" == "John"
|
||||||
|
jsonpath "$.last_name" == "Doe"
|
||||||
|
jsonpath "$.full_name" == "John Doe"
|
||||||
|
jsonpath "$.email" count == 1
|
||||||
|
jsonpath "$.email[0].email" == "john.doe@example.com"
|
||||||
|
jsonpath "$.email[0].type" == "work"
|
||||||
|
jsonpath "$.email[0].is_primary" == true
|
||||||
|
jsonpath "$.phone" count == 1
|
||||||
|
jsonpath "$.phone[0].number" == "+1-555-0100"
|
||||||
|
jsonpath "$.phone[0].type" == "mobile"
|
||||||
|
jsonpath "$.phone[0].is_primary" == true
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 – List contacts – exactly 1 result, must be John Doe
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books/{{book_id}}/contacts
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[*].id" contains {{contact_id}}
|
||||||
|
jsonpath "$[0].first_name" == "John"
|
||||||
|
jsonpath "$[0].last_name" == "Doe"
|
||||||
|
jsonpath "$[0].full_name" == "John Doe"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 – Get John Doe by id and verify all fields
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
etag: header "ETag"
|
||||||
|
[Asserts]
|
||||||
|
header "ETag" exists
|
||||||
|
jsonpath "$.id" == {{contact_id}}
|
||||||
|
jsonpath "$.address_book_id" == {{book_id}}
|
||||||
|
jsonpath "$.first_name" == "John"
|
||||||
|
jsonpath "$.last_name" == "Doe"
|
||||||
|
jsonpath "$.full_name" == "John Doe"
|
||||||
|
jsonpath "$.email[0].email" == "john.doe@example.com"
|
||||||
|
jsonpath "$.email[0].type" == "work"
|
||||||
|
jsonpath "$.email[0].is_primary" == true
|
||||||
|
jsonpath "$.phone[0].number" == "+1-555-0100"
|
||||||
|
jsonpath "$.phone[0].type" == "mobile"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 – Update John Doe: add nickname, notes, and organisation
|
||||||
|
# Uses If-Match for optimistic concurrency
|
||||||
|
# Captures the refreshed ETag into etag_updated so that
|
||||||
|
# the original etag remains available as a stale value.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
If-Match: {{etag}}
|
||||||
|
{
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"nickname": "JD",
|
||||||
|
"organization": "ACME Corp",
|
||||||
|
"notes": "Updated via Hurl test",
|
||||||
|
"email": [
|
||||||
|
{
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"type": "work",
|
||||||
|
"is_primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"phone": [
|
||||||
|
{
|
||||||
|
"number": "+1-555-0100",
|
||||||
|
"type": "mobile",
|
||||||
|
"is_primary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
etag_updated: header "ETag"
|
||||||
|
[Asserts]
|
||||||
|
header "ETag" exists
|
||||||
|
jsonpath "$.id" == {{contact_id}}
|
||||||
|
jsonpath "$.first_name" == "John"
|
||||||
|
jsonpath "$.last_name" == "Doe"
|
||||||
|
jsonpath "$.nickname" == "JD"
|
||||||
|
jsonpath "$.organization" == "ACME Corp"
|
||||||
|
jsonpath "$.notes" == "Updated via Hurl test"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 9 – Stale ETag rejection: update with the pre-step-8 ETag
|
||||||
|
# The contact was already modified so this must return 412
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
If-Match: {{etag}}
|
||||||
|
{
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"nickname": "should-not-be-saved"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 412
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 10 – Delete John Doe (uses refreshed ETag from step 8)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
If-Match: {{etag_updated}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 11 – Address book must be empty again
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books/{{book_id}}/contacts
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 12 – Delete the personal address book
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/address-books/{{book_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 13 – Verify the address book is gone from the list
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[*].id" not contains {{book_id}}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 14 – List the system address book (OxiCloud users)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books/system/contacts
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" isCollection
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Full Hurl API test runner.
|
||||||
|
# Starts postgres + OxiCloud server, runs Hurl tests, tears everything down.
|
||||||
|
#
|
||||||
|
# Usage (from repo root):
|
||||||
|
# bash tests/api/run.sh
|
||||||
|
#
|
||||||
|
# Prerequisites: docker, cargo, hurl ≥ 4.0
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
COMMON="$REPO_ROOT/tests/common"
|
||||||
|
API_DIR="$REPO_ROOT/tests/api"
|
||||||
|
|
||||||
|
# test.env is the single source of truth for connection details and credentials.
|
||||||
|
# shellcheck source=test.env
|
||||||
|
source "$API_DIR/test.env"
|
||||||
|
|
||||||
|
# Derive server port from base_url (e.g. http://localhost:8087 → 8087)
|
||||||
|
SERVER_PORT="${base_url##*:}"
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log() { echo "[api-test] $*"; }
|
||||||
|
die() { echo "[api-test] ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
wait_for_http() {
|
||||||
|
local url="$1" timeout="${2:-60}"
|
||||||
|
local deadline=$(( $(date +%s) + timeout ))
|
||||||
|
until curl -sf "$url" >/dev/null 2>&1; do
|
||||||
|
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Teardown (always runs on exit) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
SERVER_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$SERVER_PID" ]]; then
|
||||||
|
log "Stopping OxiCloud server (pid $SERVER_PID)..."
|
||||||
|
kill "$SERVER_PID" 2>/dev/null || true
|
||||||
|
wait "$SERVER_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
bash "$COMMON/stop-db.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ── 1. Start postgres ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
bash "$COMMON/spawn-db.sh"
|
||||||
|
|
||||||
|
# ── 2. Load shared server env + port from .env ───────────────────────────────
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck source=../common/server.env
|
||||||
|
source "$COMMON/server.env"
|
||||||
|
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||||
|
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
mkdir -p "$OXICLOUD_STORAGE_PATH"
|
||||||
|
|
||||||
|
# ── 3. Start OxiCloud server ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
||||||
|
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
||||||
|
|
||||||
|
if [[ -x "$OXICLOUD_BIN" ]]; then
|
||||||
|
log "Starting pre-built OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..."
|
||||||
|
"$OXICLOUD_BIN" &
|
||||||
|
else
|
||||||
|
log "Building and starting OxiCloud server on port $SERVER_PORT..."
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
cargo run &
|
||||||
|
fi
|
||||||
|
SERVER_PID=$!
|
||||||
|
log "Waiting for server at $base_url..."
|
||||||
|
wait_for_http "$base_url/ready" 120
|
||||||
|
log "Server is ready."
|
||||||
|
|
||||||
|
# ── 4. Run Hurl tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log "Running Hurl tests..."
|
||||||
|
hurl --variables-file "$API_DIR/test.env" --test --jobs 1 \
|
||||||
|
"$API_DIR/setup.hurl" \
|
||||||
|
"$API_DIR/contacts.hurl"
|
||||||
|
|
||||||
|
log "All tests passed."
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud – First-time admin setup
|
||||||
|
# =============================================================
|
||||||
|
# Creates the initial admin account. Only works once: the
|
||||||
|
# endpoint is disabled after the first admin exists.
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# hurl --variables-file tests/api/hurl.vars --test tests/api/setup.hurl
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 – Create the first admin account
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/setup
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "{{username}}",
|
||||||
|
"email": "{{email}}",
|
||||||
|
"password": "{{password}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.username" == "{{username}}"
|
||||||
|
jsonpath "$.email" == "{{email}}"
|
||||||
|
jsonpath "$.role" == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 – Verify the endpoint is now disabled (admin exists)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/setup
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "another",
|
||||||
|
"email": "another@example.com",
|
||||||
|
"password": "irrelevant"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 403
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error_type" == "SystemAlreadyInitialized"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Test credentials for local/CI API tests — NOT real secrets.
|
||||||
|
base_url=http://localhost:8087
|
||||||
|
username=admin
|
||||||
|
email=admin@example.com
|
||||||
|
# gitguardian:ignore
|
||||||
|
password=TestPassword1!
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Shared test-server environment variables.
|
||||||
|
# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node).
|
||||||
|
# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here —
|
||||||
|
# each test suite sets those to avoid port/directory conflicts.
|
||||||
|
|
||||||
|
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||||
|
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||||
|
OXICLOUD_STATIC_PATH=./static
|
||||||
|
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
|
||||||
|
OXICLOUD_ENABLE_AUTH=true
|
||||||
|
OXICLOUD_ENABLE_TRASH=true
|
||||||
|
OXICLOUD_ENABLE_SEARCH=true
|
||||||
|
OXICLOUD_ENABLE_FILE_SHARING=true
|
||||||
|
OXICLOUD_ENABLE_MUSIC=true
|
||||||
|
OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||||
|
OXICLOUD_WOPI_ENABLED=false
|
||||||
|
OXICLOUD_OIDC_ENABLED=false
|
||||||
|
RUST_LOG=warn
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE_FILE="$(dirname "$0")/docker-compose.test.yml"
|
||||||
|
|
||||||
|
wait_for_port() {
|
||||||
|
local host="$1" port="$2" timeout="${3:-30}"
|
||||||
|
local deadline=$(( $(date +%s) + timeout ))
|
||||||
|
until nc -z "$host" "$port" 2>/dev/null; do
|
||||||
|
[[ $(date +%s) -ge $deadline ]] && echo "Timeout waiting for $host:$port" >&2 && exit 1
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[setup] Starting test postgres..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
echo "[setup] Waiting for postgres on port 5433..."
|
||||||
|
wait_for_port 127.0.0.1 5433
|
||||||
|
echo "[setup] Postgres is ready."
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE_FILE="$(dirname "$0")/docker-compose.test.yml"
|
||||||
|
|
||||||
|
echo "[teardown] Stopping test postgres..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" down -v
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
end to end tests via playwright
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pretest": "node -e \"(async()=>{await require('./spawn-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\"",
|
"pretest": "bash ../common/spawn-db.sh",
|
||||||
"test": "npx playwright test",
|
"test": "npx playwright test",
|
||||||
"posttest": "node -e \"(async()=>{await require('./stop-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\""
|
"posttest": "bash ../common/stop-db.sh"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/** Parse a KEY=VALUE env file, skipping blank lines and comments. */
|
||||||
|
function loadEnv(filePath: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
env[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonEnv = loadEnv(path.join(__dirname, '../common/server.env'));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './scenarios',
|
testDir: './scenarios',
|
||||||
@@ -31,7 +48,9 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
|
|
||||||
webServer: {
|
webServer: {
|
||||||
command: process.env.CI ? `${process.env.GITHUB_WORKSPACE}/target/debug/oxicloud` : 'cargo run',
|
command: process.env.BUILD_TARGET
|
||||||
|
? `${process.env.GITHUB_WORKSPACE}/target/${process.env.BUILD_TARGET}/oxicloud`
|
||||||
|
: 'cargo run',
|
||||||
url: 'http://localhost:8087',
|
url: 'http://localhost:8087',
|
||||||
timeout: 600_000,
|
timeout: 600_000,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
@@ -39,19 +58,9 @@ export default defineConfig({
|
|||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
DATABASE_URL: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test',
|
...commonEnv,
|
||||||
OXICLOUD_SERVER_PORT: 8087,
|
OXICLOUD_SERVER_PORT: '8087',
|
||||||
OXICLOUD_DB_CONNECTION_STRING: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test',
|
|
||||||
OXICLOUD_STORAGE_PATH: './tests/e2e/storage',
|
OXICLOUD_STORAGE_PATH: './tests/e2e/storage',
|
||||||
OXICLOUD_STATIC_PATH: './static',
|
|
||||||
OXICLOUD_JWT_SECRET: 'test-secret-do-not-use-in-prod-minimum-32-chars',
|
|
||||||
OXICLOUD_ENABLE_AUTH: 'true',
|
|
||||||
OXICLOUD_ENABLE_TRASH: 'true',
|
|
||||||
OXICLOUD_ENABLE_SEARCH: 'true',
|
|
||||||
OXICLOUD_ENABLE_FILE_SHARING: 'true',
|
|
||||||
OXICLOUD_WOPI_ENABLED: 'false',
|
|
||||||
OXICLOUD_OIDC_ENABLED: 'false',
|
|
||||||
RUST_LOG: 'warn',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
const { execSync, spawnSync } = require('child_process');
|
|
||||||
const net = require('net');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml');
|
|
||||||
const CMD = `docker compose -f ${COMPOSE_FILE}`;
|
|
||||||
|
|
||||||
function waitForPort(host, port, timeoutMs = 30_000) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
function attempt() {
|
|
||||||
const sock = net.connect(port, host);
|
|
||||||
sock.once('connect', () => { sock.destroy(); resolve(); });
|
|
||||||
sock.once('error', () => {
|
|
||||||
sock.destroy();
|
|
||||||
if (Date.now() >= deadline) return reject(new Error(`Timeout waiting for ${host}:${port}`));
|
|
||||||
setTimeout(attempt, 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
attempt();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = async function globalSetup() {
|
|
||||||
console.log('[setup] Starting test postgres (database is empty) ...');
|
|
||||||
execSync(`${CMD} down`, { stdio: 'inherit' });
|
|
||||||
execSync(`${CMD} up -d`, { stdio: 'inherit' });
|
|
||||||
console.log('[setup] Waiting for postgres on port 5433...');
|
|
||||||
await waitForPort('127.0.0.1', 5433);
|
|
||||||
console.log('[setup] Postgres is ready.');
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
const { execSync } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml');
|
|
||||||
|
|
||||||
module.exports = async function globalTeardown() {
|
|
||||||
console.log('[teardown] Stopping test postgres...');
|
|
||||||
execSync(`docker compose -f ${COMPOSE_FILE} down -v`, { stdio: 'inherit' });
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user