Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e6ef90423 | |||
| c5f2c8369f | |||
| b79ac29a9d | |||
| 334d62825c | |||
| 2233cb423c | |||
| 196a6cdfb2 | |||
| 93f5e70128 | |||
| e5248e70ee | |||
| 018b946b5b | |||
| 863ba0d52a | |||
| d3903c5e2e | |||
| 6ea97c5b88 | |||
| c12c825b11 | |||
| 3d8f7692a8 | |||
| b21c874c14 | |||
| a4661099e5 | |||
| a46d74d999 | |||
| 92f69c8dba | |||
| 6692a85007 | |||
| 1f09079b5a | |||
| 2535c95f4e | |||
| 4fac0ca40d | |||
| 719a432a95 | |||
| 14c4512ef8 | |||
| 6f482c9245 | |||
| a6ad3707c6 | |||
| b34f8d96e3 | |||
| d948f51b10 | |||
| 5eb5b5023c | |||
| 504213ae79 | |||
| 5eeaafbe95 | |||
| 0ddc62c371 | |||
| 36d901d5ae | |||
| 0a3052d0d9 | |||
| 2b691f1792 | |||
| e3da2e006c | |||
| 4539f2f486 | |||
| c85ad95faf | |||
| 60f7234908 | |||
| 1dd5698389 | |||
| 3a0096f8df | |||
| 7bcffd1e96 | |||
| 9674366312 | |||
| a7581a1d1b | |||
| 0ff442d61c | |||
| 96598510ee | |||
| 02cb1f5491 | |||
| 3856698d0a | |||
| 3a05f0cfb3 | |||
| fe5e7f7a60 | |||
| b7257f4e59 | |||
| 7cc852f0da | |||
| 525258be67 | |||
| 49bd3100ad | |||
| 6693bab9f9 | |||
| 8e0d64f7d3 | |||
| c97ffe8e47 | |||
| d0115170c2 | |||
| 9ec00d4de5 | |||
| 9527427782 | |||
| fbfc8b8a05 | |||
| e85000424d | |||
| 58ac60be12 | |||
| 77ef928060 | |||
| 00afac8e46 | |||
| d22cebc69a | |||
| 151d595360 | |||
| 7e02a58546 | |||
| 25dee9bfaf | |||
| f898d61077 | |||
| 0aaa3fc912 | |||
| 77fabccdb7 | |||
| 2648ef2567 | |||
| 405a57649a | |||
| 252df1d398 | |||
| 0ad81a1fd8 | |||
| dce864e947 | |||
| b9f1040dd5 | |||
| c7882adb24 | |||
| 9491d698e8 | |||
| 5b71ac3251 | |||
| 815a09be34 | |||
| c46f9eb212 | |||
| 28219e00e7 | |||
| daaf460b0c | |||
| 04f05f725c | |||
| b499aa2abe | |||
| 72df7dd1eb | |||
| 2e7774654b | |||
| 61ce873411 | |||
| 0af9657fea | |||
| 8507e480d6 | |||
| 905d18baf7 | |||
| b5e5d86790 | |||
| c35e94b0bc | |||
| c09ebe99cc | |||
| d5544a7731 | |||
| bc62f2a066 | |||
| 38adb96be4 | |||
| c76dd21eeb | |||
| c5bf4cda8a | |||
| 0b6a6b0842 | |||
| 9c4c4d4563 | |||
| 2d74b1a1fb | |||
| 26ba237142 | |||
| 7b75304c9f | |||
| 9004e14022 | |||
| e7a3dd698a | |||
| c12007c342 | |||
| f37cf8a159 | |||
| 07f2952599 | |||
| 6cf916876a | |||
| 27b11eabf9 | |||
| da31dec33e | |||
| a61bcccdac | |||
| 774de68966 | |||
| 44b77875f7 | |||
| 5469cf05f0 | |||
| 6832ae1198 | |||
| 619faaf1df | |||
| 34387ff202 | |||
| 76d3174264 | |||
| 723337754f | |||
| c9bf3d0b52 | |||
| 9f64609722 | |||
| 89becbb92b |
@@ -33,9 +33,10 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||
uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "renovate-bot-cbcoutinho"
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||
uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -66,14 +66,14 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,52 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||
|
||||
## v0.63.2 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## v0.63.1 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## v0.63.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## v0.62.0 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## v0.61.5 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
|
||||
|
||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
||||
|
||||
### Quick Query Script (Recommended for Agents)
|
||||
|
||||
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
|
||||
|
||||
```bash
|
||||
# Basic query
|
||||
./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users"
|
||||
|
||||
# Vertical output (one column per line) - useful for wide tables
|
||||
./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1"
|
||||
|
||||
# With different credentials
|
||||
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
|
||||
```
|
||||
|
||||
### Direct Docker Access
|
||||
|
||||
For interactive sessions or complex operations:
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||
@@ -264,6 +283,40 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
||||
|
||||
### SQLite Databases (MCP Services)
|
||||
|
||||
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
|
||||
|
||||
```bash
|
||||
# List tables
|
||||
./scripts/sqlitequery.py ".tables"
|
||||
|
||||
# Query specific service
|
||||
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||
./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients"
|
||||
./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords"
|
||||
|
||||
# With column headers
|
||||
./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5"
|
||||
|
||||
# JSON output
|
||||
./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions"
|
||||
|
||||
# View schema
|
||||
./scripts/sqlitequery.py -s oauth ".schema refresh_tokens"
|
||||
```
|
||||
|
||||
**Services**: `mcp` (default), `oauth`, `keycloak`, `basic`
|
||||
|
||||
**SQLite Tables**:
|
||||
- `refresh_tokens` - OAuth refresh tokens with user profiles
|
||||
- `audit_logs` - Security audit trail
|
||||
- `oauth_clients` - DCR OAuth client credentials
|
||||
- `oauth_sessions` - OAuth flow session state
|
||||
- `registered_webhooks` - Webhook registrations
|
||||
- `app_passwords` - Multi-user BasicAuth passwords
|
||||
- `alembic_version` - Migration tracking
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
**For detailed architecture, see:**
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.57.6"
|
||||
version = "0.57.39"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,118 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.37 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.36 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.35 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.34 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.33 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.32 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.31 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.30 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.29 (2026-02-04)
|
||||
|
||||
## nextcloud-mcp-server-0.57.28 (2026-02-03)
|
||||
|
||||
## nextcloud-mcp-server-0.57.27 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## nextcloud-mcp-server-0.57.26 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.25 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.24 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.23 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.22 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.21 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.20 (2026-01-29)
|
||||
|
||||
## nextcloud-mcp-server-0.57.19 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.18 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.17 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.16 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## nextcloud-mcp-server-0.57.15 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## nextcloud-mcp-server-0.57.14 (2026-01-26)
|
||||
|
||||
## nextcloud-mcp-server-0.57.13 (2026-01-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.12 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.11 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.10 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.9 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.8 (2026-01-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.7 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## nextcloud-mcp-server-0.57.6 (2026-01-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.5 (2026-01-16)
|
||||
|
||||
@@ -4,6 +4,6 @@ dependencies:
|
||||
version: 1.16.3
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.37.0
|
||||
digest: sha256:0ce3bb4b5e95a3b8fde3f5f374d7b62aeafcb0dcf8a60b9d95978530b6c05b68
|
||||
generated: "2026-01-08T11:11:12.857375888Z"
|
||||
version: 1.41.0
|
||||
digest: sha256:1d5b958a64eb2102cf347ec199638bfac5b289bafdecff2529099ee6bce03b86
|
||||
generated: "2026-02-04T11:09:21.837825534Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.57.6
|
||||
appVersion: "0.61.5"
|
||||
version: 0.57.39
|
||||
appVersion: "0.63.2"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -31,6 +31,6 @@ dependencies:
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.37.0"
|
||||
version: "1.41.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -118,6 +118,25 @@ ingress:
|
||||
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
||||
|
||||
#### Data Storage
|
||||
|
||||
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
|
||||
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
|
||||
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
|
||||
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
|
||||
| `dataStorage.existingClaim` | Use existing PVC | `""` |
|
||||
|
||||
**When to enable persistence:**
|
||||
- Multi-user basic auth with offline access (stores `tokens.db`)
|
||||
- Qdrant persistent mode (stores vector database)
|
||||
- Any feature requiring persistent app data
|
||||
|
||||
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
|
||||
|
||||
#### MCP Server Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|
||||
@@ -120,6 +120,55 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
||||
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
||||
{{- end }}
|
||||
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- if or $legacyMultiUserBasic $legacyQdrant }}
|
||||
|
||||
================================================================================
|
||||
DEPRECATION WARNING
|
||||
================================================================================
|
||||
|
||||
You are using deprecated persistence configuration that will be removed in a
|
||||
future release. Your deployment will continue to work, but please migrate to
|
||||
the new unified dataStorage configuration.
|
||||
|
||||
Deprecated settings detected:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.* (currently enabled)
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.* (currently enabled)
|
||||
{{- end }}
|
||||
|
||||
To migrate, update your values.yaml:
|
||||
|
||||
dataStorage:
|
||||
enabled: true
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
size: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
size: {{ .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
# storageClass: "" # Optional: specify storage class
|
||||
# existingClaim: "" # Optional: use existing PVC to preserve data
|
||||
|
||||
After migrating, remove the deprecated settings:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.enabled
|
||||
- auth.multiUserBasic.persistence.size
|
||||
- auth.multiUserBasic.persistence.storageClass
|
||||
- auth.multiUserBasic.persistence.accessMode
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.enabled
|
||||
- qdrant.localPersistence.size
|
||||
- qdrant.localPersistence.storageClass
|
||||
- qdrant.localPersistence.accessMode
|
||||
{{- end }}
|
||||
|
||||
================================================================================
|
||||
{{- end }}
|
||||
|
||||
For more information and documentation:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
@@ -127,6 +127,55 @@ Create the name of the PVC to use for Qdrant local persistent storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for /app/data storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
|
||||
{{- if .Values.dataStorage.existingClaim }}
|
||||
{{- .Values.dataStorage.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Determine if data storage PVC should be enabled (backward compatible)
|
||||
Checks new dataStorage.enabled OR legacy persistence configs
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
|
||||
{{- if .Values.dataStorage.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy multi-user-basic persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy qdrant persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the MCP server port
|
||||
*/}}
|
||||
|
||||
@@ -286,14 +286,8 @@ spec:
|
||||
- name: oauth-storage
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -305,15 +299,12 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
||||
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -16,38 +16,34 @@ spec:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
|
||||
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- $accessMode := .Values.dataStorage.accessMode }}
|
||||
{{- $storageClass := .Values.dataStorage.storageClass }}
|
||||
{{- $size := .Values.dataStorage.size }}
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- $size = .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
- {{ $accessMode }}
|
||||
{{- if $storageClass }}
|
||||
storageClassName: {{ $storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.qdrant.localPersistence.size }}
|
||||
storage: {{ $size }}
|
||||
{{- end }}
|
||||
|
||||
@@ -139,6 +139,27 @@ auth:
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# Data Storage Configuration
|
||||
# Persistent volume for /app/data directory
|
||||
# Used for: token databases, qdrant persistent storage, and any app data
|
||||
# When disabled, uses emptyDir (non-persistent, but still writable)
|
||||
dataStorage:
|
||||
# Enable persistent storage for /app/data
|
||||
# Set to true when using:
|
||||
# - Multi-user basic auth with offline access (stores tokens.db)
|
||||
# - Qdrant persistent mode (stores vector database)
|
||||
# - Any feature requiring persistent app data
|
||||
# Set to false for basic auth without persistence (uses emptyDir)
|
||||
enabled: false
|
||||
# Storage class (leave empty for default)
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
# Size for data storage (should accommodate tokens.db and/or qdrant data)
|
||||
# Recommended: 1Gi minimum, 5Gi for production with qdrant
|
||||
size: 1Gi
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# MCP server configuration
|
||||
mcp:
|
||||
# Transport mode (default: streamable-http for SSE)
|
||||
|
||||
+4
-4
@@ -19,11 +19,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
||||
image: docker.io/library/redis:alpine@sha256:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.4@sha256:9ca3f78fcca340ea32ab7bf1a01b2a2fd3eae64ffc0e791fd71eb9d72c3d2efe
|
||||
image: docker.io/library/nextcloud:32.0.5@sha256:4b66e9bd8cb2c8af5457c1e2606c9937af2fcccbe4f6338956bc5990caec8968
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
@@ -54,14 +54,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:66d420cc54ef85bcc1d72220e83d7aaa6c4850bd2904794e3a56f09fd4ccb66e
|
||||
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:9945a842ba983afcf110053cbcc0df7e4bd09ba9f02aa213824ce3f986713635
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
# Authentication Flows by Deployment Mode
|
||||
|
||||
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
|
||||
|
||||
## Quick Reference Matrix
|
||||
|
||||
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|
||||
|------|-------------------|-----------------|-----------------|
|
||||
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
|
||||
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
|
||||
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
|
||||
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
|
||||
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
This document covers three distinct communication patterns:
|
||||
|
||||
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
|
||||
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
|
||||
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
|
||||
|
||||
---
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### 1. Single-User BasicAuth
|
||||
|
||||
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no auth required) │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ Authorization: Basic │
|
||||
│ │ (embedded credentials) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
|
||||
- Single shared `NextcloudClient` created at startup
|
||||
- No MCP-level authentication required (server trusts local clients)
|
||||
- All requests use the same Nextcloud user
|
||||
|
||||
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
|
||||
|
||||
**Implementation:** Background jobs use `get_settings()` to access credentials
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-User BasicAuth
|
||||
|
||||
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ Authorization: Basic │ │
|
||||
│ (user credentials) │ │
|
||||
│ │── BasicAuthMiddleware ────▶│
|
||||
│ │ Extracts credentials │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (pass-through) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
|
||||
- Credentials passed through to Nextcloud (not stored)
|
||||
- Client created per-request from extracted credentials
|
||||
- Stateless - no credential storage between requests
|
||||
|
||||
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
|
||||
|
||||
#### Background Sync (Optional)
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Store App Password ──────▶│ │
|
||||
│ (via management API) │ │
|
||||
│ │── Store in SQLite ────────▶│
|
||||
│ │ (encrypted) │
|
||||
│◀── Confirmation ────────────│ │
|
||||
│ │ │
|
||||
│ [Background Job] │ │
|
||||
│ │── Retrieve app password ──▶│
|
||||
│ │ (from encrypted storage) │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (stored app password) │
|
||||
│ │◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- `ENABLE_OFFLINE_ACCESS=true`
|
||||
- `TOKEN_ENCRYPTION_KEY` for credential encryption
|
||||
- `TOKEN_STORAGE_DB` for SQLite storage path
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
|
||||
│ (user initiates) │ │
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ (management API calls) │ │
|
||||
│ │── Validate via JWKS ──────▶│
|
||||
│ │ (or introspection) │
|
||||
│◀── API Response ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
|
||||
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
|
||||
- Authorization check: `token.sub == requested_resource_owner`
|
||||
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
|
||||
|
||||
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
|
||||
|
||||
---
|
||||
|
||||
### 3. OAuth Single-Audience (Default)
|
||||
|
||||
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
|
||||
|
||||
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: ["mcp-server", │ │
|
||||
│ "nextcloud"] │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ (UnifiedTokenVerifier) │
|
||||
│ │ │
|
||||
│ │── HTTP + Same Token ──────▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │ (multi-audience token) │
|
||||
│ │ │
|
||||
│ │ NC validates its own aud │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
|
||||
- MCP server validates only MCP audience (per RFC 7519)
|
||||
- Nextcloud independently validates its own audience
|
||||
- No token exchange needed - same token used throughout
|
||||
- Stateless operation for interactive requests
|
||||
|
||||
**Token validation flow:**
|
||||
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
|
||||
2. Token passed directly to Nextcloud via `get_client_from_context()`
|
||||
3. Nextcloud validates its own audience when receiving API calls
|
||||
|
||||
**Implementation:**
|
||||
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
|
||||
- `context.py:96-99` - Uses token directly in multi-audience mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[Background Job starts] │ │
|
||||
│── Get refresh token ──────▶│
|
||||
│ (from encrypted storage) │
|
||||
│ │
|
||||
│── Token refresh request ──▶│
|
||||
│ grant_type=refresh_token │
|
||||
│ scope=openid profile ... │
|
||||
│◀── New access + refresh ───│
|
||||
│ (rotation) │
|
||||
│ │
|
||||
│── Store rotated refresh ──▶│
|
||||
│ (encrypted) │
|
||||
│ │
|
||||
│── HTTP + Access Token ────▶│
|
||||
│ Authorization: Bearer │
|
||||
│◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
|
||||
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
|
||||
- `TokenBrokerService` handles token lifecycle
|
||||
- Per-user locking prevents race conditions during concurrent refresh
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
|
||||
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 4. OAuth Token Exchange (RFC 8693)
|
||||
|
||||
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
|
||||
|
||||
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: "mcp-server" │ │
|
||||
│ (MCP audience only) │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ │
|
||||
│ │── RFC 8693 Exchange ──────▶│
|
||||
│ │ grant_type= │
|
||||
│ │ urn:ietf:params:oauth: │
|
||||
│ │ grant-type:token-exchange
|
||||
│ │ subject_token=<mcp-token>│
|
||||
│ │ requested_audience= │
|
||||
│ │ "nextcloud" │
|
||||
│ │◀── Delegated Token ────────│
|
||||
│ │ aud: "nextcloud" │
|
||||
│ │ │
|
||||
│ │── HTTP + Delegated Token ─▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Strict audience separation: MCP token has `aud: "mcp-server"` only
|
||||
- Server exchanges for Nextcloud-audience token on each request
|
||||
- Ephemeral delegated tokens (not cached by default)
|
||||
- Strongest security boundary between MCP and Nextcloud access
|
||||
|
||||
**Token exchange details:**
|
||||
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
- Subject token: MCP access token
|
||||
- Requested audience: Nextcloud resource URI
|
||||
- Result: Short-lived token scoped for Nextcloud
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
|
||||
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
|
||||
- `context.py:88-94` - Routes to session client in exchange mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[User provisions access] │ │
|
||||
│── Flow 2 OAuth ───────────▶│
|
||||
│ client_id="mcp-server" │
|
||||
│ scope=offline_access ... │
|
||||
│◀── Refresh Token ──────────│
|
||||
│ (stored encrypted) │
|
||||
│ │
|
||||
[Background Job runs later] │ │
|
||||
│── Refresh for background ─▶│
|
||||
│ (same as single-audience)│
|
||||
```
|
||||
|
||||
**Key difference from interactive:**
|
||||
- Interactive: On-demand token exchange per request
|
||||
- Background: Uses pre-provisioned refresh tokens (Flow 2)
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 5. Smithery Stateless
|
||||
|
||||
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
|
||||
|
||||
Enabled by `SMITHERY_DEPLOYMENT=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── SSE Connect ─────────────▶│ │
|
||||
│ ?nextcloud_url=... │ │
|
||||
│ &username=... │ │
|
||||
│ &app_password=... │ │
|
||||
│ │── SmitheryConfigMiddleware │
|
||||
│ │ Extract URL params │
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no Authorization header) │ │
|
||||
│ │── Create per-request ─────▶│
|
||||
│ │ NextcloudClient │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (from session params) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Configuration passed via URL query parameters (Smithery `configSchema`)
|
||||
- No persistent state - client created fresh per request
|
||||
- No OAuth infrastructure
|
||||
- No background sync support (stateless)
|
||||
- No admin UI available
|
||||
|
||||
**Required session parameters:**
|
||||
- `nextcloud_url`: Nextcloud instance URL
|
||||
- `username`: Nextcloud username
|
||||
- `app_password`: Nextcloud app password
|
||||
|
||||
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Not supported. Smithery mode is fully stateless with no credential storage.
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable. Smithery deployments don't integrate with Astrolabe.
|
||||
|
||||
---
|
||||
|
||||
## Astrolabe Background Token Refresh
|
||||
|
||||
The Astrolabe Nextcloud app includes a background job that proactively refreshes OAuth tokens before expiration.
|
||||
|
||||
```
|
||||
Nextcloud Cron Astrolabe MCP Server IdP
|
||||
│ │ │
|
||||
│── Run RefreshUserTokens ───▶│ │
|
||||
│ (every 15 minutes) │ │
|
||||
│ │── Get all user tokens ────▶│
|
||||
│ │ (from preferences) │
|
||||
│ │ │
|
||||
│ [For each user] │ │
|
||||
│ │── Check expiry ───────────▶│
|
||||
│ │ refresh if <50% lifetime │
|
||||
│ │ │
|
||||
│ │── Acquire user lock ──────▶│
|
||||
│ │ (prevent race condition) │
|
||||
│ │ │
|
||||
│ │── Token refresh request ──▶│
|
||||
│ │ grant_type=refresh_token │
|
||||
│ │◀── New tokens ─────────────│
|
||||
│ │ │
|
||||
│ │── Store new tokens ───────▶│
|
||||
│ │ (with issued_at) │
|
||||
│◀── Job complete ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Runs every 15 minutes via Nextcloud cron
|
||||
- Refreshes when <50% of token lifetime remains
|
||||
- Uses locking to prevent race conditions with on-demand refresh
|
||||
- Stores `issued_at` timestamp for accurate lifetime calculation
|
||||
- Batch processing (100 users at a time) for memory efficiency
|
||||
|
||||
**Implementation:** `third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php`
|
||||
|
||||
---
|
||||
|
||||
## Configuration Quick Reference
|
||||
|
||||
### Single-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://localhost:8080
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
```
|
||||
|
||||
### Multi-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Single-Audience (Default)
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
# No username/password triggers OAuth mode
|
||||
|
||||
# Optional: Static client credentials (instead of DCR)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Token Exchange
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### Smithery Stateless
|
||||
```bash
|
||||
SMITHERY_DEPLOYMENT=true
|
||||
# All other config comes from session URL parameters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Authentication](authentication.md) - Configuration details and setup guides
|
||||
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
|
||||
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
|
||||
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
|
||||
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
|
||||
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
|
||||
@@ -0,0 +1,206 @@
|
||||
# Introducing Astrolabe: Navigate Your Data Universe in Nextcloud
|
||||
|
||||
Your Nextcloud instance holds years of notes, projects, recipes, contacts, and documents. But when you need to find something, you're stuck typing exact keywords and hoping for the best. Search "car repair" and miss that note titled "Vehicle maintenance tips." Search "meeting agenda" and overlook the calendar event called "Team sync." Traditional keyword search demands that you remember exactly how you wrote things down.
|
||||
|
||||
What if your search could understand what you *mean*, not just what you type?
|
||||
|
||||
Meet **Astrolabe**—a Nextcloud app that brings AI-powered semantic search to your self-hosted cloud. Named after the ancient navigational instrument that helped travelers chart courses by the stars, Astrolabe helps you navigate your personal knowledge by mapping the semantic connections between your documents.
|
||||
|
||||
## The Astrolabe Metaphor
|
||||
|
||||
The astrolabe was one of humanity's most elegant scientific instruments—an analog computer for solving problems related to time and the position of celestial bodies. Its theoretical foundation traces back to **Hipparchus of Nicaea** (c. 190–120 BCE), who discovered the stereographic projection that allows a three-dimensional celestial sphere to be represented on a flat surface. Later Greek scholars like **Theon of Alexandria** and his daughter **Hypatia** refined it into a practical instrument, and during the Islamic Golden Age, astronomers in Baghdad, Damascus, and Cordoba perfected its design and applications.
|
||||
|
||||
For nearly two millennia, astrolabes served astronomers, navigators, scholars, and religious officials across the Greek, Byzantine, Islamic, and medieval European worlds. These instruments allowed users to determine time, find celestial positions, calculate daylight hours, identify constellations, and even determine the direction of Mecca for prayer—all without complex calculations. The astrolabe made the vast complexity of the heavens understandable and navigable.
|
||||
|
||||
**Astrolabe** (the app) does the same for your data. Every document, note, and calendar event becomes a point of light in your personal data universe. The app maps their semantic relationships—their meaning, not just their words—and suddenly the connections become visible. Documents cluster by topic, related ideas sit nearby, and you can navigate this landscape as naturally as medieval scholars once read the stars. Where the original astrolabe projected the celestial sphere onto brass, this one projects your knowledge into explorable semantic space.
|
||||
|
||||
## Semantic Search: Find Meaning, Not Just Keywords
|
||||
|
||||
The core feature of Astrolabe is semantic search. Instead of matching exact keywords, it understands the concepts in your query and finds related content.
|
||||
|
||||
**What this looks like in practice:**
|
||||
|
||||
| You Search For | Traditional Search Finds | Astrolabe Also Finds |
|
||||
|----------------|--------------------------|----------------------|
|
||||
| "car repair" | Documents containing "car repair" | Notes about "vehicle maintenance," "fixing the truck" |
|
||||
| "team planning" | Documents with "team planning" | Calendar events titled "Q2 kickoff," Deck cards about "project roadmap" |
|
||||
| "pasta recipes" | Documents with "pasta recipes" | Notes about "Italian cooking," "homemade noodles," "carbonara tips" |
|
||||
|
||||
This works across multiple Nextcloud apps: Notes, Files (including PDFs with OCR), Deck cards, Calendar events, Contacts, and News/RSS items. One search bar, all your content, understood by meaning.
|
||||
|
||||
### Hybrid Search: Best of Both Worlds
|
||||
|
||||
Sometimes you want exact matches ("PROJ-2024-001"), sometimes you want semantic understanding ("that project from last year about authentication"). Astrolabe's hybrid search combines both approaches:
|
||||
|
||||
- **Semantic search** uses embeddings to find conceptually related content
|
||||
- **BM25 keyword search** finds exact matches and important terms
|
||||
- **Reciprocal Rank Fusion (RRF)** intelligently merges the results
|
||||
|
||||
You can adjust the balance or switch modes entirely depending on your needs.
|
||||
|
||||

|
||||
*Astrolabe results appear alongside traditional search in Nextcloud's unified search bar*
|
||||
|
||||
## Visualize Your Data Universe
|
||||
|
||||
Beyond search, Astrolabe includes an interactive 3D visualization that shows your documents positioned in semantic space. Similar documents cluster together. Topics form constellations. You can rotate, zoom, and explore.
|
||||
|
||||
This isn't just eye candy—it's a practical tool for knowledge discovery:
|
||||
|
||||
- **Find forgotten connections**: Search for your current project and watch as related documents from months ago light up nearby
|
||||
- **Spot topic clusters**: See how your notes naturally group by subject
|
||||
- **Explore the unknown**: Click on points near your search results to discover content you didn't know was related
|
||||
|
||||
The visualization uses Principal Component Analysis (PCA) to project high-dimensional embeddings (768 dimensions) down to 3D space while preserving the relationships between documents. We implemented a lightweight, custom PCA specifically for this—no heavyweight ML libraries required.
|
||||
|
||||

|
||||
*Documents cluster by semantic similarity. The query point (red) shows your search, and related documents cluster nearby*
|
||||
|
||||
## Power Your AI Agents
|
||||
|
||||
Astrolabe isn't just for humans—it's for your AI assistants too.
|
||||
|
||||
The backend runs a **Model Context Protocol (MCP)** server, which means AI tools like Claude Desktop, Cursor, or custom agents can connect directly to your Nextcloud data. Your AI assistant can:
|
||||
|
||||
- Search your notes semantically ("Find everything related to the Kubernetes migration")
|
||||
- Retrieve document content for context
|
||||
- Get AI-generated answers with citations from your documents (RAG)
|
||||
|
||||
The critical point: **your data never leaves your infrastructure**. The MCP server runs on your hardware. Your AI assistant sends queries, the server returns results, and you maintain full control. No documents uploaded to third-party services.
|
||||
|
||||
### Retrieval-Augmented Generation (RAG)
|
||||
|
||||
Ask a question, and Astrolabe can retrieve relevant documents and have your AI synthesize an answer—complete with citations:
|
||||
|
||||
```
|
||||
You: "What were the main issues we had deploying to production last month?"
|
||||
|
||||
Astrolabe finds: 3 relevant notes, 2 Deck cards, 1 calendar event
|
||||
|
||||
AI generates: "Based on your documents, there were three main issues:
|
||||
1. Database migration timeout (see Note: 'Prod deploy 2024-01-15')
|
||||
2. SSL certificate renewal (see Deck card: 'Ops Tasks')
|
||||
3. Resource limits on the new pods (see Note: 'K8s troubleshooting')
|
||||
```
|
||||
|
||||
This uses MCP's sampling capability—the server doesn't run its own LLM. Instead, it asks your client's AI to generate the response. You choose the model, you control the costs.
|
||||
|
||||
## Under the Hood
|
||||
|
||||
For the technically curious, here's how Astrolabe works:
|
||||
|
||||
### Embedding Providers
|
||||
|
||||
Astrolabe supports multiple backends for generating semantic embeddings:
|
||||
|
||||
- **Amazon Bedrock**: Enterprise-grade, Titan embeddings
|
||||
- **OpenAI**: Direct OpenAI API or compatible endpoints (including GitHub Models)
|
||||
- **Ollama**: Self-hosted, privacy-focused, runs entirely on your hardware
|
||||
|
||||
The system auto-detects available providers based on environment variables and falls back gracefully. Deploy Ollama on your server for full privacy, or use Bedrock for enterprise scale—same codebase, zero code changes.
|
||||
|
||||
### Background Indexing
|
||||
|
||||
Documents are indexed automatically via webhooks. When you create or edit a note, Nextcloud fires an event, and the MCP server processes it in the background. No manual sync required.
|
||||
|
||||
The indexing pipeline:
|
||||
1. **Scanner** detects changes via ETags and modification timestamps
|
||||
2. **Queue** manages backpressure (up to 10k pending documents)
|
||||
3. **Worker pool** processes embeddings concurrently (configurable, default 3 workers)
|
||||
4. **Qdrant** stores vectors for fast similarity search
|
||||
|
||||
### Lightweight by Design
|
||||
|
||||
We deliberately avoided heavyweight dependencies:
|
||||
|
||||
- **Custom PCA**: No scikit-learn, just efficient eigendecomposition
|
||||
- **In-process async**: No separate message queues or worker processes—just anyio TaskGroups
|
||||
- **Plugin architecture**: New apps (Notes, Calendar, etc.) are simple scanner/processor implementations
|
||||
|
||||
This means Astrolabe runs comfortably alongside your Nextcloud on modest hardware.
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌─────────────┐ ┌─────────┐
|
||||
│ Nextcloud │────▶│ MCP Server │────▶│ Qdrant │
|
||||
│ (Astrolabe) │◀────│ (Python) │◀────│ (Vectors)│
|
||||
└──────────────┘ └─────────────┘ └─────────┘
|
||||
│ │
|
||||
│ OAuth/Token │ Embeddings
|
||||
▼ ▼
|
||||
┌────────┐ ┌──────────┐
|
||||
│ User │ │ Ollama/ │
|
||||
│Browser │ │ Bedrock │
|
||||
└────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
|
||||
- Nextcloud 31 or 32
|
||||
- MCP server instance (Docker recommended)
|
||||
- Vector database (Qdrant, included in Docker setup)
|
||||
- Embedding provider (Ollama for self-hosted, or cloud options)
|
||||
|
||||
### Quick Setup
|
||||
|
||||
1. **Install the Astrolabe app** from the Nextcloud App Store (or manually)
|
||||
|
||||
2. **Start the MCP server** (Docker Compose makes this easy):
|
||||
```bash
|
||||
docker compose up -d mcp qdrant ollama
|
||||
```
|
||||
|
||||
3. **Configure the connection** in your Nextcloud `config.php`:
|
||||
```php
|
||||
'astrolabe' => [
|
||||
'mcp_server_url' => 'http://localhost:8000',
|
||||
],
|
||||
```
|
||||
|
||||
4. **Authorize access** in Settings → Personal → Astrolabe
|
||||
|
||||
5. **Start searching** using Nextcloud's unified search bar
|
||||
|
||||
For detailed setup instructions, including OAuth configuration and embedding provider options, see the [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server).
|
||||
|
||||
## What Can You Index?
|
||||
|
||||
Astrolabe currently supports:
|
||||
|
||||
| App | What Gets Indexed |
|
||||
|-----|-------------------|
|
||||
| **Notes** | Full text and metadata |
|
||||
| **Files** | PDFs (with OCR), DOCX, text files |
|
||||
| **Deck** | Card titles and descriptions |
|
||||
| **Calendar** | Event titles, descriptions, and details |
|
||||
| **Contacts** | Names, notes, and contact information |
|
||||
| **News** | RSS/Atom feed articles |
|
||||
|
||||
Each result shows the document type, relevance score, and a direct link to the source. For large documents, it shows which chunk (section) matched.
|
||||
|
||||

|
||||
*Click a result to see the matching chunk in context*
|
||||
|
||||
## Who Is This For?
|
||||
|
||||
**Researchers and students**: Find all notes related to your thesis topic, even when you used different terminology across semesters. Discover connections between papers you read months apart.
|
||||
|
||||
**Teams and organizations**: Surface institutional knowledge that would otherwise stay buried. New team members can search for concepts instead of knowing exactly what to look for.
|
||||
|
||||
**Developers**: Connect your AI coding assistant to your Nextcloud. Give it access to project notes, meeting records, and documentation without copy-pasting context.
|
||||
|
||||
**Personal knowledge managers**: Discover forgotten documents related to your current work. Watch your knowledge base evolve over time through the visualization.
|
||||
|
||||
## Try It Out
|
||||
|
||||
Astrolabe is open source (AGPL) and ready to use. Your data universe has been waiting in the dark—it's time to turn on the lights.
|
||||
|
||||
- **Install**: [Nextcloud App Store](https://apps.nextcloud.com/apps/astrolabe)
|
||||
- **Source**: [GitHub](https://github.com/cbcoutinho/nextcloud-mcp-server)
|
||||
- **Documentation**: [Setup Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/tree/master/docs)
|
||||
- **Issues**: [Report bugs or request features](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
|
||||
|
||||
---
|
||||
|
||||
*Astrolabe is maintained by [Chris Coutinho](https://github.com/cbcoutinho). Contributions welcome.*
|
||||
@@ -3,4 +3,74 @@
|
||||
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
||||
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
||||
authentication via the UnifiedTokenVerifier.
|
||||
|
||||
This package is organized into modules by domain:
|
||||
- management.py: Server status, user sessions, shared helpers
|
||||
- passwords.py: App password provisioning for multi-user BasicAuth
|
||||
- webhooks.py: Webhook registration management
|
||||
- visualization.py: Search and PDF visualization endpoints
|
||||
"""
|
||||
|
||||
# Re-export all public functions for backward compatibility
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
__version__,
|
||||
_parse_float_param,
|
||||
_parse_int_param,
|
||||
_sanitize_error_for_client,
|
||||
_validate_query_string,
|
||||
extract_bearer_token,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
revoke_user_access,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
from nextcloud_mcp_server.api.passwords import (
|
||||
delete_app_password,
|
||||
get_app_password_status,
|
||||
provision_app_password,
|
||||
)
|
||||
from nextcloud_mcp_server.api.visualization import (
|
||||
get_chunk_context,
|
||||
get_pdf_preview,
|
||||
unified_search,
|
||||
vector_search,
|
||||
)
|
||||
from nextcloud_mcp_server.api.webhooks import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
get_installed_apps,
|
||||
list_webhooks,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Version
|
||||
"__version__",
|
||||
# Shared helpers (from management.py)
|
||||
"extract_bearer_token",
|
||||
"validate_token_and_get_user",
|
||||
"_sanitize_error_for_client",
|
||||
"_parse_int_param",
|
||||
"_parse_float_param",
|
||||
"_validate_query_string",
|
||||
# Status endpoints (from management.py)
|
||||
"get_server_status",
|
||||
"get_vector_sync_status",
|
||||
# Session endpoints (from management.py)
|
||||
"get_user_session",
|
||||
"revoke_user_access",
|
||||
# Password endpoints (from passwords.py)
|
||||
"provision_app_password",
|
||||
"get_app_password_status",
|
||||
"delete_app_password",
|
||||
# Webhook endpoints (from webhooks.py)
|
||||
"get_installed_apps",
|
||||
"list_webhooks",
|
||||
"create_webhook",
|
||||
"delete_webhook",
|
||||
# Visualization endpoints (from visualization.py)
|
||||
"unified_search",
|
||||
"vector_search",
|
||||
"get_chunk_context",
|
||||
"get_pdf_preview",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,429 @@
|
||||
"""App password management API endpoints.
|
||||
|
||||
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
|
||||
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||
- Store app passwords for background sync operations
|
||||
- Check app password status
|
||||
- Delete stored app passwords
|
||||
|
||||
Authentication is via BasicAuth with the user's Nextcloud credentials.
|
||||
Passwords are validated against Nextcloud before being stored.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
APP_PASSWORD_PATTERN = re.compile(
|
||||
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
|
||||
)
|
||||
|
||||
# Timeout for Nextcloud API validation requests (seconds)
|
||||
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
|
||||
|
||||
# Rate limiting configuration for app password provisioning
|
||||
# Limits: 5 attempts per user per hour
|
||||
RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
|
||||
|
||||
# In-memory rate limiter storage
|
||||
# Structure: {user_id: [(timestamp, success), ...]}
|
||||
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
|
||||
"""Check if user is rate limited for app password operations.
|
||||
|
||||
Implements a sliding window rate limiter to prevent brute-force attacks
|
||||
on the app password provisioning endpoint.
|
||||
|
||||
Args:
|
||||
user_id: User identifier to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed, seconds_until_retry)
|
||||
- is_allowed: True if request should be allowed
|
||||
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
|
||||
"""
|
||||
current_time = time.time()
|
||||
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
|
||||
|
||||
# Clean up old attempts outside the window
|
||||
_rate_limit_attempts[user_id] = [
|
||||
(ts, success)
|
||||
for ts, success in _rate_limit_attempts[user_id]
|
||||
if ts > window_start
|
||||
]
|
||||
|
||||
# Count recent attempts (both successful and failed)
|
||||
recent_attempts = len(_rate_limit_attempts[user_id])
|
||||
|
||||
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||
# Find when the oldest attempt in the window will expire
|
||||
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
|
||||
seconds_until_retry = int(
|
||||
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
|
||||
)
|
||||
return False, max(1, seconds_until_retry)
|
||||
|
||||
return True, 0
|
||||
|
||||
|
||||
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
|
||||
"""Record an app password provisioning attempt for rate limiting.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
success: Whether the attempt was successful
|
||||
"""
|
||||
_rate_limit_attempts[user_id].append((time.time(), success))
|
||||
|
||||
|
||||
def _extract_basic_auth(
|
||||
request: Request, path_user_id: str
|
||||
) -> tuple[str, str, JSONResponse | None]:
|
||||
"""Extract and validate BasicAuth credentials from request.
|
||||
|
||||
Validates:
|
||||
1. Authorization header is present and valid BasicAuth format
|
||||
2. Username in credentials matches the path user_id
|
||||
|
||||
Args:
|
||||
request: Starlette request with Authorization header
|
||||
path_user_id: User ID from the URL path to verify against
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password, error_response)
|
||||
- If successful: (username, password, None)
|
||||
- If failed: ("", "", JSONResponse with error)
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header or not auth_header.startswith("Basic "):
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Missing BasicAuth credentials"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Decode BasicAuth
|
||||
encoded = auth_header.split(" ", 1)[1]
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
except Exception:
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Invalid BasicAuth format"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
# Verify username matches path user_id
|
||||
if username != path_user_id:
|
||||
logger.warning(
|
||||
f"Username mismatch in app password operation for path user {path_user_id}"
|
||||
)
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Username does not match path user_id"},
|
||||
status_code=403,
|
||||
),
|
||||
)
|
||||
|
||||
return username, password, None
|
||||
|
||||
|
||||
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||
|
||||
Checks app.state.storage first, then falls back to creating from environment.
|
||||
This helper avoids repeated storage initialization logic across endpoints.
|
||||
|
||||
Args:
|
||||
request: Starlette request with app state
|
||||
|
||||
Returns:
|
||||
Initialized RefreshTokenStorage instance
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = getattr(request.app.state, "storage", None)
|
||||
|
||||
if not storage:
|
||||
# Multi-user BasicAuth mode may not have oauth_context
|
||||
# Initialize storage from environment
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
return storage
|
||||
|
||||
|
||||
async def provision_app_password(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
|
||||
|
||||
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
|
||||
for multi-user BasicAuth mode background sync.
|
||||
|
||||
The request must include BasicAuth credentials where:
|
||||
- username: Nextcloud user ID (must match path user_id)
|
||||
- password: The app password being provisioned
|
||||
|
||||
The MCP server validates the app password against Nextcloud before storing it.
|
||||
This proves the user owns the password and has access to Nextcloud.
|
||||
|
||||
Security model:
|
||||
- User identity is verified via BasicAuth against Nextcloud
|
||||
- App password is encrypted before storage
|
||||
- Only the user who owns the password can provision it
|
||||
- Rate limited to prevent brute-force attacks
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check rate limit before processing
|
||||
is_allowed, retry_after = _check_rate_limit(path_user_id)
|
||||
if not is_allowed:
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for app password provisioning: {path_user_id}"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||
},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return error_response
|
||||
|
||||
# Validate app password format
|
||||
if not APP_PASSWORD_PATTERN.match(app_password):
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password format"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Nextcloud host from settings
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
if not nextcloud_host:
|
||||
logger.error("NEXTCLOUD_HOST not configured")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Server not configured"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Validate app password against Nextcloud
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
# Use OCS API to verify credentials
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, app_password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"App password validation failed for user: HTTP {response.status_code}"
|
||||
)
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Verify the user ID from response matches
|
||||
data = response.json()
|
||||
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
|
||||
if ocs_user_id != username:
|
||||
logger.warning("User ID mismatch in OCS response")
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "User ID mismatch"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate app password: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Store the validated app password
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
await storage.store_app_password(username, app_password)
|
||||
|
||||
_record_rate_limit_attempt(path_user_id, success=True)
|
||||
logger.info(f"Provisioned app password for user: {username}")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password stored for {username}",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "provision_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_app_password_status(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
|
||||
|
||||
Returns status of background sync access for multi-user BasicAuth mode.
|
||||
|
||||
Requires BasicAuth with the user's app password for authentication.
|
||||
"""
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
app_password = await storage.get_app_password(username)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": username,
|
||||
"has_app_password": app_password is not None,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_app_password(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
|
||||
|
||||
Removes the user's app password from MCP server storage.
|
||||
|
||||
Requires BasicAuth with the user's credentials.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
# Validate credentials against Nextcloud
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid credentials"},
|
||||
status_code=401,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate credentials: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
deleted = await storage.delete_app_password(username)
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted app password for user: {username}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password deleted for {username}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": "No app password found to delete",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "delete_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -0,0 +1,813 @@
|
||||
"""Visualization API endpoints for search and PDF preview.
|
||||
|
||||
ADR-018: Provides REST API endpoints for the Nextcloud PHP app (Astrolabe) to:
|
||||
- Execute unified search with semantic/BM25/hybrid algorithms
|
||||
- Execute vector search with PCA visualization coordinates
|
||||
- Fetch chunk context with surrounding text
|
||||
- Render PDF pages server-side (avoiding CSP/worker issues)
|
||||
|
||||
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pymupdf
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
_parse_float_param,
|
||||
_parse_int_param,
|
||||
_sanitize_error_for_client,
|
||||
_validate_query_string,
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def unified_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
||||
|
||||
Optimized search endpoint for the Nextcloud Unified Search provider
|
||||
and other PHP app integrations. Returns results with metadata needed
|
||||
for navigation to source documents.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 20, // max: 100
|
||||
"offset": 0, // pagination offset
|
||||
"include_pca": false, // optional PCA coordinates
|
||||
"include_chunks": true // include text snippets
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"results": [{
|
||||
"id": "doc123",
|
||||
"doc_type": "note",
|
||||
"title": "Document Title",
|
||||
"excerpt": "Matching text snippet...",
|
||||
"score": 0.85,
|
||||
"path": "/path/to/file.txt", // for files
|
||||
"board_id": 1, // for deck cards
|
||||
"card_id": 42
|
||||
}],
|
||||
"total_found": 150,
|
||||
"algorithm_used": "hybrid"
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
|
||||
# Validate and parse parameters
|
||||
try:
|
||||
query = body.get("query", "")
|
||||
_validate_query_string(query, max_length=10000)
|
||||
|
||||
limit = _parse_int_param(
|
||||
str(body.get("limit")) if body.get("limit") is not None else None,
|
||||
20,
|
||||
1,
|
||||
100,
|
||||
"limit",
|
||||
)
|
||||
|
||||
offset = _parse_int_param(
|
||||
str(body.get("offset")) if body.get("offset") is not None else None,
|
||||
0,
|
||||
0,
|
||||
1000000,
|
||||
"offset",
|
||||
)
|
||||
|
||||
score_threshold = _parse_float_param(
|
||||
body.get("score_threshold"),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
"score_threshold",
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
include_pca = body.get("include_pca", False)
|
||||
include_chunks = body.get("include_chunks", True)
|
||||
doc_types = body.get("doc_types") # Optional filter
|
||||
|
||||
if not query:
|
||||
return JSONResponse({"results": [], "total_found": 0})
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Request extra results to handle offset
|
||||
search_limit = limit + offset
|
||||
|
||||
# Execute search
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
for doc_type in doc_types:
|
||||
if doc_type:
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
else:
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
)
|
||||
|
||||
# Sort results by score (no deduplication - show all chunks)
|
||||
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Calculate total and apply pagination
|
||||
total_found = len(sorted_results)
|
||||
paginated_results = sorted_results[offset : offset + limit]
|
||||
|
||||
# Format results for Unified Search
|
||||
formatted_results = []
|
||||
for result in paginated_results:
|
||||
# Get document ID (prefer note_id for notes)
|
||||
doc_id = result.id
|
||||
if result.metadata and "note_id" in result.metadata:
|
||||
doc_id = result.metadata["note_id"]
|
||||
|
||||
result_data: dict[str, Any] = {
|
||||
"id": doc_id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"score": result.score,
|
||||
}
|
||||
|
||||
# Include excerpt/chunk if requested (full content, no truncation)
|
||||
if include_chunks and result.excerpt:
|
||||
result_data["excerpt"] = result.excerpt
|
||||
|
||||
# Include navigation metadata from result.metadata
|
||||
if result.metadata:
|
||||
# File path and mimetype for files
|
||||
if "path" in result.metadata:
|
||||
result_data["path"] = result.metadata["path"]
|
||||
if "mime_type" in result.metadata:
|
||||
result_data["mime_type"] = result.metadata["mime_type"]
|
||||
|
||||
# Deck card navigation
|
||||
if "board_id" in result.metadata:
|
||||
result_data["board_id"] = result.metadata["board_id"]
|
||||
if "card_id" in result.metadata:
|
||||
result_data["card_id"] = result.metadata["card_id"]
|
||||
|
||||
# Calendar event metadata
|
||||
if "calendar_id" in result.metadata:
|
||||
result_data["calendar_id"] = result.metadata["calendar_id"]
|
||||
if "event_uid" in result.metadata:
|
||||
result_data["event_uid"] = result.metadata["event_uid"]
|
||||
|
||||
# Add PDF page metadata
|
||||
if result.page_number is not None:
|
||||
result_data["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
result_data["page_count"] = result.page_count
|
||||
|
||||
# Add chunk metadata (always present, defaults to 0 and 1)
|
||||
result_data["chunk_index"] = result.chunk_index
|
||||
result_data["total_chunks"] = result.total_chunks
|
||||
|
||||
# Add chunk offsets for modal navigation
|
||||
if result.chunk_start_offset is not None:
|
||||
result_data["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
result_data["chunk_end_offset"] = result.chunk_end_offset
|
||||
|
||||
formatted_results.append(result_data)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"total_found": total_found,
|
||||
"algorithm_used": algorithm,
|
||||
}
|
||||
|
||||
# Optional PCA coordinates
|
||||
if include_pca and len(paginated_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(
|
||||
paginated_results, query_embedding
|
||||
)
|
||||
response_data["pca_data"] = pca_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA for unified search: {e}")
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in unified search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def vector_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/vector-viz/search - Vector search for visualization.
|
||||
|
||||
Executes semantic search and returns results with optional PCA coordinates
|
||||
for 2D visualization.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 10, // max: 50
|
||||
"include_pca": true, // whether to include 2D coordinates
|
||||
"doc_types": ["note", "file"] // optional filter by document types
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "vector_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
score_threshold = body.get("score_threshold", 0.0)
|
||||
limit = min(body.get("limit", 10), 50) # Enforce max limit
|
||||
include_pca = body.get("include_pca", True)
|
||||
doc_types = body.get("doc_types") # Optional list of document types
|
||||
|
||||
if not query:
|
||||
return JSONResponse(
|
||||
{"error": "Missing required parameter: query"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
|
||||
# which combines dense semantic and sparse BM25 vectors
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Execute search for each doc_type if specified, otherwise search all
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
# Search each doc_type separately and merge results
|
||||
for doc_type in doc_types:
|
||||
if doc_type: # Skip empty strings
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
# Sort merged results by score and limit
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
all_results = all_results[:limit]
|
||||
else:
|
||||
# Search all document types
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Format results for PHP client
|
||||
formatted_results = []
|
||||
for result in all_results:
|
||||
formatted_result = {
|
||||
"id": result.id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"excerpt": result.excerpt[:200] if result.excerpt else "",
|
||||
"score": result.score,
|
||||
"metadata": result.metadata,
|
||||
# Chunk information for context display
|
||||
"chunk_index": result.chunk_index,
|
||||
"total_chunks": result.total_chunks,
|
||||
}
|
||||
# Include optional fields if present
|
||||
if result.chunk_start_offset is not None:
|
||||
formatted_result["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
formatted_result["chunk_end_offset"] = result.chunk_end_offset
|
||||
if result.page_number is not None:
|
||||
formatted_result["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
formatted_result["page_count"] = result.page_count
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"algorithm_used": algorithm,
|
||||
"total_documents": len(formatted_results),
|
||||
}
|
||||
|
||||
# Compute PCA coordinates for visualization using shared function
|
||||
if include_pca and len(all_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
# Get query embedding from search algorithm or generate it
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(all_results, query_embedding)
|
||||
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
|
||||
response_data["query_coords"] = pca_data["query_coords"]
|
||||
if "pca_variance" in pca_data:
|
||||
response_data["pca_variance"] = pca_data["pca_variance"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA coordinates: {e}")
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
elif include_pca:
|
||||
# Not enough results for PCA
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "vector_search")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/chunk-context - Fetch chunk text with context.
|
||||
|
||||
Retrieves the matched chunk along with surrounding text and metadata.
|
||||
Used by clients to display chunk context and highlighted PDFs.
|
||||
|
||||
Query parameters:
|
||||
doc_type: Document type (e.g., "note")
|
||||
doc_id: Document ID
|
||||
start: Chunk start offset (character position)
|
||||
end: Chunk end offset (character position)
|
||||
context: Characters of context before/after (default: 500)
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_chunk_context"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get query parameters
|
||||
doc_type = request.query_params.get("doc_type")
|
||||
doc_id = request.query_params.get("doc_id")
|
||||
start_str = request.query_params.get("start")
|
||||
end_str = request.query_params.get("end")
|
||||
|
||||
# Validate required parameters
|
||||
if not all([doc_type, doc_id, start_str, end_str]):
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing required parameters: doc_type, doc_id, start, end",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Type narrowing: we already checked these are not None above
|
||||
assert start_str is not None
|
||||
assert end_str is not None
|
||||
assert doc_id is not None
|
||||
assert doc_type is not None
|
||||
|
||||
# Parse and validate integer parameters with bounds checking
|
||||
try:
|
||||
context_chars = _parse_int_param(
|
||||
request.query_params.get("context"),
|
||||
500,
|
||||
0,
|
||||
10000,
|
||||
"context_chars",
|
||||
)
|
||||
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
|
||||
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
|
||||
if end <= start:
|
||||
raise ValueError("end must be greater than start")
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
# Convert doc_id to int if possible (most IDs are int)
|
||||
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
|
||||
|
||||
# Get bearer token for client initialization
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Initialize authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=user_id,
|
||||
doc_id=doc_id_val,
|
||||
doc_type=doc_type,
|
||||
chunk_start=start,
|
||||
chunk_end=end,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
if chunk_context is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant if available
|
||||
# This is useful for clients that want to show a pre-rendered image
|
||||
highlighted_page_image = None
|
||||
page_number = chunk_context.page_number
|
||||
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(
|
||||
key="doc_id", match=MatchValue(value=doc_id_val)
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=user_id)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
)
|
||||
|
||||
if points_response[0]:
|
||||
payload = points_response[0][0].payload
|
||||
if payload:
|
||||
highlighted_page_image = payload.get("highlighted_page_image")
|
||||
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
|
||||
if payload.get("page_number") is not None:
|
||||
page_number = payload.get("page_number")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
"success": True,
|
||||
"chunk_text": chunk_context.chunk_text,
|
||||
"before_context": chunk_context.before_context,
|
||||
"after_context": chunk_context.after_context,
|
||||
"has_more_before": chunk_context.has_before_truncation,
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
"page_number": page_number,
|
||||
"chunk_index": chunk_context.chunk_index,
|
||||
"total_chunks": chunk_context.total_chunks,
|
||||
}
|
||||
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_pdf_preview(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/pdf-preview - Render PDF page to PNG image.
|
||||
|
||||
Server-side PDF rendering using PyMuPDF. This endpoint allows Astrolabe
|
||||
to display PDF pages without requiring client-side PDF.js, avoiding CSP
|
||||
worker restrictions and ES private field issues in Chromium.
|
||||
|
||||
Query parameters:
|
||||
file_path: WebDAV path to PDF file (e.g., "/Documents/report.pdf")
|
||||
page: Page number (1-indexed, default: 1)
|
||||
scale: Zoom factor for rendering (default: 2.0 = 144 DPI)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"image": "<base64-encoded-png>",
|
||||
"page_number": 1,
|
||||
"total_pages": 10
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
# Log incoming request
|
||||
file_path_param = request.query_params.get("file_path", "<not provided>")
|
||||
page_param = request.query_params.get("page", "1")
|
||||
logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}")
|
||||
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
logger.info(f"PDF preview authenticated for user: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_pdf_preview"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse and validate parameters
|
||||
file_path = request.query_params.get("file_path")
|
||||
if not file_path:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing required parameter: file_path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate no path traversal sequences
|
||||
if ".." in file_path:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid file path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
page_num = _parse_int_param(
|
||||
request.query_params.get("page"), 1, 1, 10000, "page"
|
||||
)
|
||||
scale = _parse_float_param(
|
||||
request.query_params.get("scale"), 2.0, 0.5, 5.0, "scale"
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
|
||||
# Get bearer token for WebDAV authentication
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Download PDF via WebDAV using user's token
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
pdf_bytes, _ = await nc_client.webdav.read_file(file_path)
|
||||
|
||||
# Check file size limit (50 MB)
|
||||
max_pdf_size = 50 * 1024 * 1024
|
||||
if len(pdf_bytes) > max_pdf_size:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"PDF file exceeds maximum size limit ({max_pdf_size // (1024 * 1024)} MB)",
|
||||
},
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
# Render page with PyMuPDF
|
||||
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
||||
try:
|
||||
total_pages = doc.page_count
|
||||
|
||||
# Validate page number
|
||||
if page_num > total_pages:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Page {page_num} does not exist (document has {total_pages} pages)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
page = doc[page_num - 1] # 0-indexed
|
||||
mat = pymupdf.Matrix(scale, scale)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
png_bytes = pix.tobytes("png")
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
# Encode as base64
|
||||
image_b64 = base64.b64encode(png_bytes).decode("ascii")
|
||||
|
||||
logger.info(
|
||||
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, "
|
||||
f"{len(png_bytes):,} bytes"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"image": image_b64,
|
||||
"page_number": page_num,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"PDF file not found: {file_path_param}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "PDF file not found"},
|
||||
status_code=404,
|
||||
)
|
||||
except (pymupdf.FileDataError, pymupdf.EmptyFileError):
|
||||
logger.warning(f"Invalid or corrupted PDF file: {file_path_param}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid or corrupted PDF file"},
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"PDF preview error: {e}", exc_info=True)
|
||||
error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Webhook management API endpoints.
|
||||
|
||||
Provides REST API endpoints for managing webhook registrations with Nextcloud.
|
||||
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||
- List installed Nextcloud apps
|
||||
- Create, list, and delete webhook registrations
|
||||
|
||||
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
_sanitize_error_for_client,
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||
|
||||
Returns a list of installed app IDs for filtering webhook presets.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Get installed apps using OCS API
|
||||
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
|
||||
# We check which ones are installed and enabled
|
||||
ocs_url = "/ocs/v1.php/cloud/apps"
|
||||
params = {"filter": "enabled"}
|
||||
|
||||
response = await client.get(
|
||||
ocs_url,
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"OCS API returned status {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
|
||||
|
||||
return JSONResponse({"apps": apps})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting installed apps for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def list_webhooks(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/webhooks - List all registered webhooks.
|
||||
|
||||
Returns list of webhook registrations for the authenticated user.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to list webhooks
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhooks = await webhooks_client.list_webhooks()
|
||||
|
||||
return JSONResponse({"webhooks": webhooks})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing webhooks for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def create_webhook(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/webhooks - Create a new webhook registration.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"uri": "http://mcp:8000/webhooks/nextcloud",
|
||||
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
|
||||
}
|
||||
|
||||
Returns the created webhook data including the webhook ID.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
event = body.get("event")
|
||||
uri = body.get("uri")
|
||||
# Accept both camelCase (eventFilter) and snake_case (event_filter)
|
||||
event_filter = body.get("eventFilter") or body.get("event_filter")
|
||||
|
||||
if not event or not uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Bad request",
|
||||
"message": "Missing required fields: event, uri",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to create webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhook_data = await webhooks_client.create_webhook(
|
||||
event=event, uri=uri, event_filter=event_filter
|
||||
)
|
||||
|
||||
return JSONResponse({"webhook": webhook_data})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_webhook(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
|
||||
|
||||
Returns success/failure status.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get webhook_id from path parameter
|
||||
webhook_id = request.path_params.get("webhook_id")
|
||||
if not webhook_id:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Missing webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
webhook_id = int(webhook_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Invalid webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to delete webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
await webhooks_client.delete_webhook(webhook_id=webhook_id)
|
||||
|
||||
return JSONResponse({"success": True, "message": "Webhook deleted"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -2112,13 +2112,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||
)
|
||||
if enable_management_apis:
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
from nextcloud_mcp_server.api import (
|
||||
create_webhook,
|
||||
delete_app_password,
|
||||
delete_webhook,
|
||||
get_app_password_status,
|
||||
get_chunk_context,
|
||||
get_installed_apps,
|
||||
get_pdf_preview,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
@@ -2179,6 +2180,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
routes.append(
|
||||
Route("/api/v1/chunk-context", get_chunk_context, methods=["GET"])
|
||||
)
|
||||
# PDF preview endpoint for Astrolabe (server-side rendering)
|
||||
routes.append(Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]))
|
||||
# ADR-018: Unified search endpoint for Nextcloud PHP app integration
|
||||
routes.append(Route("/api/v1/search", unified_search, methods=["POST"]))
|
||||
routes.append(Route("/api/v1/apps", get_installed_apps, methods=["GET"]))
|
||||
@@ -2193,7 +2196,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
||||
"/api/v1/users/{user_id}/app-password, "
|
||||
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||
"/api/v1/webhooks"
|
||||
"/api/v1/webhooks, /api/v1/pdf-preview"
|
||||
)
|
||||
|
||||
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||
|
||||
@@ -255,8 +255,14 @@ class CalendarClient:
|
||||
"""List events in a calendar within date range."""
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
# Get all events using caldav library (now with proper filter)
|
||||
events = await calendar.events()
|
||||
if start_datetime or end_datetime:
|
||||
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||
events = await self._search_events_by_date(
|
||||
calendar, start_datetime, end_datetime
|
||||
)
|
||||
else:
|
||||
# No date filter — fetch all events
|
||||
events = await calendar.events()
|
||||
|
||||
result = []
|
||||
for event in events:
|
||||
@@ -274,6 +280,52 @@ class CalendarClient:
|
||||
logger.debug(f"Found {len(result)} events")
|
||||
return result
|
||||
|
||||
async def _search_events_by_date(
|
||||
self,
|
||||
calendar: AsyncCalendar,
|
||||
start_datetime: Optional[dt.datetime] = None,
|
||||
end_datetime: Optional[dt.datetime] = None,
|
||||
) -> list:
|
||||
"""Execute a CalDAV REPORT with time-range filter."""
|
||||
from caldav.async_collection import AsyncEvent
|
||||
from caldav.elements import cdav, dav
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
# Ensure naive datetimes are treated as UTC
|
||||
if start_datetime and start_datetime.tzinfo is None:
|
||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||
if end_datetime and end_datetime.tzinfo is None:
|
||||
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
|
||||
|
||||
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
|
||||
inner_comp_filter = cdav.CompFilter(name="VEVENT")
|
||||
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
|
||||
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||
filter_element = cdav.Filter() + outer_comp_filter
|
||||
|
||||
query = (
|
||||
cdav.CalendarQuery() + [dav.Prop() + cdav.CalendarData()] + filter_element
|
||||
)
|
||||
|
||||
body = etree.tostring(
|
||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
assert calendar.client is not None
|
||||
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||
|
||||
# Parse response (same pattern as AsyncCalendar.search)
|
||||
objects = []
|
||||
response_data = response.expand_simple_props([cdav.CalendarData()])
|
||||
for href, props in response_data.items():
|
||||
if href == str(calendar.url):
|
||||
continue
|
||||
cal_data = props.get(cdav.CalendarData.tag)
|
||||
if cal_data:
|
||||
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||
objects.append(obj)
|
||||
|
||||
return objects
|
||||
|
||||
async def create_event(
|
||||
self, calendar_name: str, event_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.61.5"
|
||||
version = "0.63.2"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -114,7 +114,7 @@ caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx
|
||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||
requires = ["uv_build>=0.10.0,<0.11.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database query helper for development.
|
||||
|
||||
Wraps `docker compose exec db mariadb` to execute SQL statements against
|
||||
the Nextcloud MariaDB database.
|
||||
|
||||
Usage:
|
||||
./scripts/dbquery.py "SELECT * FROM oc_notes LIMIT 5"
|
||||
./scripts/dbquery.py -u root -p password "SHOW TABLES"
|
||||
./scripts/dbquery.py --json "SELECT * FROM oc_oidc_clients"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_compose_dir() -> Path:
|
||||
"""Find the directory containing docker-compose.yml."""
|
||||
current = Path(__file__).resolve().parent
|
||||
while current != current.parent:
|
||||
if (current / "docker-compose.yml").exists():
|
||||
return current
|
||||
if (current / "compose.yml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Default to script's parent directory
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run_query(
|
||||
sql: str,
|
||||
user: str = "root",
|
||||
password: str = "password",
|
||||
database: str = "nextcloud",
|
||||
vertical: bool = False,
|
||||
json_output: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""
|
||||
Execute SQL via docker compose exec.
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
compose_dir = find_compose_dir()
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T", # Disable pseudo-TTY allocation
|
||||
"db",
|
||||
"mariadb",
|
||||
f"-u{user}",
|
||||
f"-p{password}",
|
||||
database,
|
||||
"-e",
|
||||
sql,
|
||||
]
|
||||
|
||||
if vertical:
|
||||
cmd.insert(-2, "-E") # Vertical output format
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=compose_dir,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute SQL queries against the Nextcloud MariaDB database",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s "SELECT COUNT(*) FROM oc_notes"
|
||||
%(prog)s "SELECT id, name FROM oc_oidc_clients"
|
||||
%(prog)s -E "SELECT * FROM oc_users LIMIT 1"
|
||||
%(prog)s --user nextcloud --password nextcloud "SHOW TABLES"
|
||||
""",
|
||||
)
|
||||
parser.add_argument("sql", help="SQL statement to execute")
|
||||
parser.add_argument(
|
||||
"-u", "--user", default="root", help="Database user (default: root)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--password",
|
||||
default="password",
|
||||
help="Database password (default: password)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--database",
|
||||
default="nextcloud",
|
||||
help="Database name (default: nextcloud)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-E",
|
||||
"--vertical",
|
||||
action="store_true",
|
||||
help="Print output vertically (one column per line)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json_output",
|
||||
help="Request JSON output (if supported)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
returncode, stdout, stderr = run_query(
|
||||
sql=args.sql,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
vertical=args.vertical,
|
||||
json_output=args.json_output,
|
||||
)
|
||||
|
||||
if stdout:
|
||||
print(stdout, end="")
|
||||
if stderr:
|
||||
# Filter out the password warning
|
||||
filtered_stderr = "\n".join(
|
||||
line
|
||||
for line in stderr.splitlines()
|
||||
if "Using a password on the command line interface can be insecure"
|
||||
not in line
|
||||
)
|
||||
if filtered_stderr:
|
||||
print(filtered_stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite database query helper for MCP service development.
|
||||
|
||||
Wraps `docker compose exec <service> sqlite3` to execute SQL statements
|
||||
against the token storage database in any MCP service container.
|
||||
|
||||
Usage:
|
||||
./scripts/sqlitequery.py ".tables"
|
||||
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||
./scripts/sqlitequery.py -s keycloak --headers "SELECT * FROM oauth_clients"
|
||||
./scripts/sqlitequery.py --json "SELECT * FROM audit_logs LIMIT 5"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Service name aliases for convenience
|
||||
SERVICE_ALIASES = {
|
||||
"mcp": "mcp",
|
||||
"oauth": "mcp-oauth",
|
||||
"mcp-oauth": "mcp-oauth",
|
||||
"keycloak": "mcp-keycloak",
|
||||
"mcp-keycloak": "mcp-keycloak",
|
||||
"basic": "mcp-multi-user-basic",
|
||||
"multi-user-basic": "mcp-multi-user-basic",
|
||||
"mcp-multi-user-basic": "mcp-multi-user-basic",
|
||||
}
|
||||
|
||||
|
||||
def find_compose_dir() -> Path:
|
||||
"""Find the directory containing docker-compose.yml."""
|
||||
current = Path(__file__).resolve().parent
|
||||
while current != current.parent:
|
||||
if (current / "docker-compose.yml").exists():
|
||||
return current
|
||||
if (current / "compose.yml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Default to script's parent directory
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def resolve_service(service: str) -> str:
|
||||
"""Resolve service alias to container name."""
|
||||
resolved = SERVICE_ALIASES.get(service.lower())
|
||||
if resolved is None:
|
||||
# Not a known alias, use as-is (might be a custom service)
|
||||
return service
|
||||
return resolved
|
||||
|
||||
|
||||
def run_query(
|
||||
sql: str,
|
||||
service: str = "mcp",
|
||||
database: str = "/app/data/tokens.db",
|
||||
headers: bool = False,
|
||||
json_output: bool = False,
|
||||
column_mode: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""
|
||||
Execute SQL via docker compose exec.
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
compose_dir = find_compose_dir()
|
||||
container = resolve_service(service)
|
||||
|
||||
# Build sqlite3 command with options
|
||||
sqlite_args = []
|
||||
|
||||
# Set output mode
|
||||
if json_output:
|
||||
sqlite_args.extend(["-json"])
|
||||
elif column_mode:
|
||||
sqlite_args.extend(["-column"])
|
||||
|
||||
# Enable headers
|
||||
if headers or column_mode:
|
||||
sqlite_args.extend(["-header"])
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T", # Disable pseudo-TTY allocation
|
||||
container,
|
||||
"sqlite3",
|
||||
*sqlite_args,
|
||||
database,
|
||||
sql,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=compose_dir,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute SQL queries against SQLite databases in MCP service containers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Services:
|
||||
mcp Single-user BasicAuth mode (default)
|
||||
oauth Nextcloud OAuth mode (mcp-oauth)
|
||||
keycloak Keycloak OAuth mode (mcp-keycloak)
|
||||
basic Multi-user BasicAuth mode (mcp-multi-user-basic)
|
||||
|
||||
Examples:
|
||||
%(prog)s ".tables"
|
||||
%(prog)s -s oauth "SELECT user_id FROM refresh_tokens"
|
||||
%(prog)s -s keycloak ".schema oauth_clients"
|
||||
%(prog)s --headers "SELECT * FROM audit_logs LIMIT 5"
|
||||
%(prog)s --json "SELECT * FROM oauth_sessions"
|
||||
""",
|
||||
)
|
||||
parser.add_argument("sql", help="SQL statement or SQLite command to execute")
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--service",
|
||||
default="mcp",
|
||||
help="Target service (mcp, oauth, keycloak, basic) (default: mcp)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--database",
|
||||
default="/app/data/tokens.db",
|
||||
help="Database path inside container (default: /app/data/tokens.db)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headers",
|
||||
action="store_true",
|
||||
help="Show column headers",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json_output",
|
||||
help="Output in JSON format",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--column",
|
||||
action="store_true",
|
||||
dest="column_mode",
|
||||
help="Output in column format with headers",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
returncode, stdout, stderr = run_query(
|
||||
sql=args.sql,
|
||||
service=args.service,
|
||||
database=args.database,
|
||||
headers=args.headers,
|
||||
json_output=args.json_output,
|
||||
column_mode=args.column_mode,
|
||||
)
|
||||
|
||||
if stdout:
|
||||
print(stdout, end="")
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -380,6 +380,86 @@ async def test_event_with_url_and_categories(
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_date_range_filtering(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that date range filtering actually excludes events outside the range.
|
||||
|
||||
Reproduces GH-538: get_calendar_events() accepted date range parameters
|
||||
but returned events from the entire calendar history, ignoring date filters.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
past_uid = None
|
||||
future_uid = None
|
||||
|
||||
try:
|
||||
# Create Event A: 30 days in the past
|
||||
past_date = datetime.now() - timedelta(days=30)
|
||||
past_event_data = {
|
||||
"title": f"Past Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Event in the past for date range test",
|
||||
}
|
||||
result_past = await nc_client.calendar.create_event(
|
||||
calendar_name, past_event_data
|
||||
)
|
||||
past_uid = result_past["uid"]
|
||||
logger.info(f"Created past event: {past_uid}")
|
||||
|
||||
# Create Event B: 1 day in the future
|
||||
future_date = datetime.now() + timedelta(days=1)
|
||||
future_event_data = {
|
||||
"title": f"Future Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Event in the future for date range test",
|
||||
}
|
||||
result_future = await nc_client.calendar.create_event(
|
||||
calendar_name, future_event_data
|
||||
)
|
||||
future_uid = result_future["uid"]
|
||||
logger.info(f"Created future event: {future_uid}")
|
||||
|
||||
# Query with date range: today → 7 days ahead
|
||||
now = datetime.now()
|
||||
week_ahead = now + timedelta(days=7)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=week_ahead,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
event_uids = [e["uid"] for e in events]
|
||||
|
||||
# Future event (tomorrow) SHOULD be in results
|
||||
assert future_uid in event_uids, (
|
||||
f"Future event {future_uid} should be in date-filtered results"
|
||||
)
|
||||
|
||||
# Past event (30 days ago) should NOT be in results
|
||||
assert past_uid not in event_uids, (
|
||||
f"Past event {past_uid} should be excluded by date range filter "
|
||||
f"(GH-538: date range was being ignored)"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Date range filtering works: {len(events)} events returned, "
|
||||
f"past event correctly excluded"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup both events
|
||||
for uid in [past_uid, future_uid]:
|
||||
if uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
|
||||
+31
-22
@@ -2351,32 +2351,41 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating editors group (may already exist): {e}")
|
||||
|
||||
# Create each test user
|
||||
# Create each test user (idempotent - check if exists first)
|
||||
for username, config in test_user_configs.items():
|
||||
# Check if user already exists
|
||||
user_exists = False
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=username,
|
||||
password=config["password"],
|
||||
display_name=config["display_name"],
|
||||
email=config["email"],
|
||||
)
|
||||
logger.info(f"Created test user: {username}")
|
||||
created_users.append(username)
|
||||
await nc_client.users.get_user_details(username)
|
||||
user_exists = True
|
||||
logger.info(f"Test user {username} already exists, skipping creation")
|
||||
except Exception:
|
||||
# User doesn't exist, proceed with creation
|
||||
pass
|
||||
|
||||
# Add user to groups if specified
|
||||
for group in config["groups"]:
|
||||
try:
|
||||
await nc_client.users.add_user_to_group(username, group)
|
||||
logger.info(f"Added {username} to group {group}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error adding {username} to group {group}: {e}")
|
||||
if not user_exists:
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=username,
|
||||
password=config["password"],
|
||||
display_name=config["display_name"],
|
||||
email=config["email"],
|
||||
)
|
||||
logger.info(f"Created test user: {username}")
|
||||
created_users.append(username) # Only track users WE created
|
||||
|
||||
except Exception as e:
|
||||
# User might already exist, that's okay
|
||||
logger.warning(
|
||||
f"Could not create user {username} (may already exist): {e}"
|
||||
)
|
||||
created_users.append(username) # Add to list anyway for cleanup
|
||||
# Add user to groups if specified
|
||||
for group in config["groups"]:
|
||||
try:
|
||||
await nc_client.users.add_user_to_group(username, group)
|
||||
logger.info(f"Added {username} to group {group}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error adding {username} to group {group}: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create user {username}: {e}")
|
||||
|
||||
logger.info(f"Test users setup complete: {created_users}")
|
||||
yield test_user_configs
|
||||
|
||||
@@ -43,8 +43,19 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
|
||||
# Submit form
|
||||
await page.click('button[type="submit"]')
|
||||
# Submit form - use force=True to bypass stability check (CSS transitions)
|
||||
submit_button = page.locator('button[type="submit"]')
|
||||
try:
|
||||
await submit_button.click(force=True, timeout=10000)
|
||||
except Exception:
|
||||
# Fallback: JavaScript click
|
||||
logger.info("Using JavaScript click for login button...")
|
||||
await page.evaluate(
|
||||
"""
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
"""
|
||||
)
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
# Verify logged in (should redirect away from login page)
|
||||
@@ -75,6 +86,289 @@ async def navigate_to_astrolabe_settings(page: Page):
|
||||
logger.info("✓ Successfully loaded Astrolabe settings page")
|
||||
|
||||
|
||||
async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
"""Complete Step 1: OAuth Authorization for Astrolabe.
|
||||
|
||||
Handles the OAuth flow:
|
||||
1. Check if already authorized (Step 1 shows "Complete")
|
||||
2. Click "Authorize" link
|
||||
3. Handle Nextcloud OIDC consent screen
|
||||
4. Wait for redirect back to Astrolabe settings
|
||||
5. Verify "Complete" badge appears on Step 1
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be on Astrolabe settings page)
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if authorization completed successfully
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Authorizing search access (Step 1) for {username}...")
|
||||
|
||||
# Check if already on Astrolabe settings page, if not navigate there
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Wait for page to fully render
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already authorized (either "Active" badge or Step 1 "Complete" badge)
|
||||
try:
|
||||
# Check for "Active" badge (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ Already fully authorized for {username} (Active badge)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
# Look for "Complete" text in the Step 1 section's parent
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 1 already complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find and click the "Authorize" button
|
||||
authorize_button = page.locator('a.button.primary:has-text("Authorize")')
|
||||
|
||||
try:
|
||||
await authorize_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"Found Authorize button for {username}")
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Could not find Authorize button for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"Authorize button not found for {username}")
|
||||
|
||||
# Click the Authorize button - this will redirect to OAuth provider
|
||||
# Use force=True to bypass stability check which can timeout due to CSS transitions
|
||||
await authorize_button.click(force=True)
|
||||
logger.info(f"Clicked Authorize button for {username}")
|
||||
|
||||
# Wait for OAuth redirect to complete
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info(f"After networkidle, current URL: {page.url}")
|
||||
|
||||
# Take screenshot to see current state
|
||||
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info(f"Screenshot saved: /tmp/astrolabe_after_authorize_{username}.png")
|
||||
|
||||
# Handle OIDC consent screen if present
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
if consent_handled:
|
||||
logger.info(f"✓ OAuth consent granted for {username}")
|
||||
else:
|
||||
logger.info(
|
||||
f"No consent screen required for {username} (may be previously authorized)"
|
||||
)
|
||||
|
||||
# Wait for redirect back to Astrolabe settings
|
||||
# The OAuth callback will redirect back to /settings/user/astrolabe
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000
|
||||
)
|
||||
logger.info(f"Redirected back to Astrolabe settings for {username}")
|
||||
except Exception:
|
||||
# Check if we're already on settings page
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
logger.warning(
|
||||
f"Not redirected to Astrolabe settings, current URL: {page.url}"
|
||||
)
|
||||
# Navigate manually
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
|
||||
# Wait for page to reload and render
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Verify authorization completed - check for various success indicators
|
||||
# When fully configured, shows "Active" badge; when only Step 1 done, shows "Complete"
|
||||
try:
|
||||
# First check if "Active" badge is shown (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ OAuth authorization complete for {username} (Active badge)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 1 "Complete" badge (partial configuration)
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Step 1 OAuth authorization complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neither badge found - authorization failed
|
||||
screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Authorization badge not visible for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"OAuth authorization did not complete for {username}")
|
||||
|
||||
|
||||
async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
"""Handle the OIDC consent screen during OAuth flow.
|
||||
|
||||
Reuses the proven pattern from tests/conftest.py.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if consent was handled, False if no consent screen was found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Checking for consent screen at URL: {page.url}")
|
||||
|
||||
# Check if consent screen is present - try multiple selectors
|
||||
# The consent screen may be #oidc-consent or use a different format
|
||||
consent_div = await page.query_selector("#oidc-consent")
|
||||
|
||||
if consent_div:
|
||||
logger.info(f"Consent screen detected via #oidc-consent for {username}")
|
||||
# Get consent screen data attributes for logging
|
||||
client_name = await consent_div.get_attribute("data-client-name")
|
||||
scopes_attr = await consent_div.get_attribute("data-scopes")
|
||||
logger.info(f" Client: {client_name}")
|
||||
logger.info(f" Requested scopes: {scopes_attr}")
|
||||
else:
|
||||
# Check for Allow button directly (different consent screen format)
|
||||
allow_button = page.locator('button:has-text("Allow")')
|
||||
if await allow_button.count() > 0:
|
||||
logger.info(f"Consent screen detected via Allow button for {username}")
|
||||
else:
|
||||
logger.info(f"No consent screen found for {username} at {page.url}")
|
||||
await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png")
|
||||
logger.info(f"Screenshot: /tmp/no_consent_screen_{username}.png")
|
||||
return False
|
||||
|
||||
# Wait for Vue.js to render the Allow button
|
||||
try:
|
||||
await page.wait_for_selector('button:has-text("Allow")', timeout=10000)
|
||||
logger.info(" Allow button rendered by Vue.js")
|
||||
except Exception as e:
|
||||
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f" Timeout waiting for Allow button: {e}")
|
||||
raise
|
||||
|
||||
# Check all scope checkboxes
|
||||
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
|
||||
if scope_checkboxes:
|
||||
logger.info(f" Found {len(scope_checkboxes)} scope checkboxes")
|
||||
for i, checkbox in enumerate(scope_checkboxes):
|
||||
is_checked = await checkbox.is_checked()
|
||||
is_disabled = await checkbox.is_disabled()
|
||||
if not is_checked and not is_disabled:
|
||||
await checkbox.check()
|
||||
logger.info(f" ✓ Checked scope checkbox {i + 1}")
|
||||
|
||||
# Click the Allow button using JavaScript (handles viewport issues)
|
||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
||||
|
||||
# Debug: take screenshot before clicking Allow
|
||||
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
|
||||
logger.info(
|
||||
f" Screenshot before Allow: /tmp/consent_before_allow_{username}.png"
|
||||
)
|
||||
|
||||
button_count = await allow_button_locator.count()
|
||||
logger.info(f" Found {button_count} Allow button(s)")
|
||||
|
||||
if button_count > 0:
|
||||
current_url = page.url
|
||||
logger.info(f" Current URL: {current_url}")
|
||||
logger.info(f" Clicking Allow button for {username}...")
|
||||
|
||||
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py)
|
||||
# This is more reliable than Playwright's click for Vue.js rendered buttons
|
||||
await page.evaluate(
|
||||
"""
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.textContent.trim() === 'Allow') {
|
||||
btn.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Wait for URL to change (Vue.js uses window.location.href after fetch)
|
||||
# networkidle doesn't detect fetch-based redirects
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
lambda url: url != current_url,
|
||||
timeout=30000,
|
||||
)
|
||||
logger.info(f" URL changed to: {page.url}")
|
||||
except Exception as wait_error:
|
||||
# If URL didn't change, check console for errors
|
||||
logger.warning(f" URL didn't change after click: {wait_error}")
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
|
||||
# Try alternative: manually POST consent and navigate
|
||||
logger.info(" Trying manual consent submission...")
|
||||
try:
|
||||
redirect_url = await page.evaluate(
|
||||
"""
|
||||
async () => {
|
||||
const selectedScopes = Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value).join(' ');
|
||||
|
||||
const response = await fetch('/index.php/apps/oidc/consent/grant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'requesttoken': OC.requestToken,
|
||||
},
|
||||
body: 'scopes=' + encodeURIComponent(selectedScopes),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
return response.url || '/index.php/apps/oidc/authorize';
|
||||
}
|
||||
"""
|
||||
)
|
||||
logger.info(f" Manual consent returned URL: {redirect_url}")
|
||||
await page.goto(redirect_url, wait_until="networkidle")
|
||||
except Exception as manual_error:
|
||||
logger.error(f" Manual consent also failed: {manual_error}")
|
||||
raise
|
||||
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
logger.info(f" Consent granted for {username}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f" Allow button not found for {username}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling consent screen for {username}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
|
||||
) -> str:
|
||||
@@ -105,16 +399,32 @@ async def generate_app_password(
|
||||
await anyio.sleep(1.0)
|
||||
logger.info("Waited for Vue.js to process input and enable button")
|
||||
|
||||
# Click the create button
|
||||
# Click the create button - use force=True to bypass stability check (CSS transitions)
|
||||
create_button = page.locator(
|
||||
'button[type="submit"]:has-text("Create new app password")'
|
||||
)
|
||||
await create_button.click()
|
||||
try:
|
||||
await create_button.click(force=True, timeout=10000)
|
||||
except Exception:
|
||||
# Fallback: JavaScript click
|
||||
logger.info("Using JavaScript click for create button...")
|
||||
await page.evaluate(
|
||||
"""
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
"""
|
||||
)
|
||||
logger.info("Clicked create app password button")
|
||||
|
||||
# Wait for app password to be generated and displayed in the dialog
|
||||
await anyio.sleep(3) # Give it more time to generate and display
|
||||
|
||||
# Debug screenshot after clicking create
|
||||
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
|
||||
logger.info(
|
||||
f"Screenshot after create: /tmp/app_password_after_create_{username}.png"
|
||||
)
|
||||
|
||||
# Find the Login input field which should have the username value
|
||||
# Then find the Password input field which is in the same form
|
||||
app_password = None
|
||||
@@ -172,11 +482,11 @@ async def generate_app_password(
|
||||
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
||||
)
|
||||
|
||||
# Close the dialog by clicking the Close button
|
||||
close_button = page.get_by_role("button", name="Close")
|
||||
await close_button.click()
|
||||
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
|
||||
logger.info("Closing app password dialog with Escape key...")
|
||||
await page.keyboard.press("Escape")
|
||||
await anyio.sleep(0.5) # Wait for dialog close animation
|
||||
logger.info("Closed app password dialog")
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
return app_password
|
||||
|
||||
@@ -226,9 +536,9 @@ async def enable_background_sync_via_app_password(
|
||||
# Wait for page to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already active (look for "Active" text in the Background Sync Access section)
|
||||
# Check if already complete (look for Step 2 "Complete" badge or overall "Active" state)
|
||||
try:
|
||||
# The "Active" badge appears as a <span> with text "Active"
|
||||
# First check for overall "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.info(f"✓ Background sync already active for {username}")
|
||||
@@ -236,6 +546,18 @@ async def enable_background_sync_via_app_password(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge (app password already set)
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 (app password) already complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find the app password input field using the placeholder text
|
||||
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||
@@ -319,21 +641,120 @@ async def enable_background_sync_via_app_password(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify "Active" text appears after reload
|
||||
# Verify Step 2 "Complete" badge or overall "Active" badge appears after reload
|
||||
try:
|
||||
# First try to find "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.count() > 0:
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Background sync enabled for {username} - Active badge visible"
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Step 2 (app password) enabled for {username} - Complete badge visible"
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If neither badge found, raise error
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Neither Active nor Complete badge appeared for {username}. "
|
||||
f"Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"Background sync setup did not complete for {username}")
|
||||
|
||||
|
||||
async def complete_astrolabe_authorization(
|
||||
page: Page, username: str, password: str
|
||||
) -> dict:
|
||||
"""Complete full Astrolabe two-step authorization.
|
||||
|
||||
Performs the complete authorization flow:
|
||||
1. Navigate to Astrolabe settings
|
||||
2. OAuth authorization (Step 1) if needed
|
||||
3. Generate app password in Security settings
|
||||
4. App password entry (Step 2) if needed
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be logged in)
|
||||
username: Nextcloud username
|
||||
password: Nextcloud password (for reference, not used directly)
|
||||
|
||||
Returns:
|
||||
Dict with {"step1": bool, "step2": bool, "app_password": str | None}
|
||||
"""
|
||||
logger.info(f"Starting full Astrolabe authorization for {username}...")
|
||||
|
||||
result = {"step1": False, "step2": False, "app_password": None}
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Step 1: OAuth authorization
|
||||
try:
|
||||
result["step1"] = await authorize_search_access(page, username)
|
||||
logger.info(f"✓ Step 1 complete for {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Step 1 failed for {username}: {e}")
|
||||
raise
|
||||
|
||||
# Navigate back to settings if needed (OAuth might have redirected elsewhere)
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Check if Step 2 is already complete
|
||||
try:
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 already complete for {username}")
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also check for overall "Active" badge
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Background sync enabled for {username} - Active badge visible")
|
||||
return True
|
||||
if await active_text.count() > 0 and await active_text.is_visible():
|
||||
logger.info(f"✓ Authorization already fully active for {username}")
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Active badge did not appear for {username}. Screenshot: {screenshot_path}"
|
||||
pass
|
||||
|
||||
# Step 2: Generate app password and enter it
|
||||
app_password = await generate_app_password(page, username)
|
||||
result["app_password"] = app_password
|
||||
|
||||
try:
|
||||
result["step2"] = await enable_background_sync_via_app_password(
|
||||
page, username, app_password
|
||||
)
|
||||
logger.info(f"✓ Step 2 complete for {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Step 2 failed for {username}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"✓ Full Astrolabe authorization complete for {username}")
|
||||
return result
|
||||
|
||||
|
||||
async def verify_app_password_created(username: str) -> bool:
|
||||
"""Verify that background sync app password was stored for the user.
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
|
||||
|
||||
This test verifies that:
|
||||
1. User can provision background sync access via app password
|
||||
2. Content created via MCP tools is indexed by vector sync
|
||||
3. Semantic search via Astrolabe UI returns results
|
||||
4. Plotly 3D visualization container renders correctly
|
||||
|
||||
Requires:
|
||||
- docker-compose up -d app db mcp-multi-user-basic
|
||||
- ENABLE_SEMANTIC_SEARCH=true on the mcp-multi-user-basic container
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from playwright.async_api import Page
|
||||
|
||||
# Import helper functions from existing test
|
||||
from tests.conftest import create_mcp_client_session
|
||||
from tests.integration.test_astrolabe_multi_user_background_sync import (
|
||||
complete_astrolabe_authorization,
|
||||
login_to_nextcloud,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def wait_for_vector_sync(
|
||||
mcp_client, initial_indexed_count: int, timeout_seconds: int = 60
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""Wait for vector sync to index new content.
|
||||
|
||||
Args:
|
||||
mcp_client: MCP client session
|
||||
initial_indexed_count: Initial indexed document count before creating content
|
||||
timeout_seconds: Maximum time to wait for sync
|
||||
|
||||
Returns:
|
||||
Tuple of (success, status_data)
|
||||
"""
|
||||
wait_interval = 2
|
||||
waited = 0
|
||||
status_data = None
|
||||
|
||||
while waited < timeout_seconds:
|
||||
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||
if sync_status.isError:
|
||||
logger.warning(f"Vector sync status error: {sync_status}")
|
||||
return False, None
|
||||
|
||||
status_data = json.loads(sync_status.content[0].text)
|
||||
indexed_count = status_data.get("indexed_count", 0)
|
||||
pending_count = status_data.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Sync status at {waited}s: indexed={indexed_count}, "
|
||||
f"pending={pending_count}, status={status_data.get('status')}"
|
||||
)
|
||||
|
||||
if indexed_count > initial_indexed_count and pending_count == 0:
|
||||
logger.info(
|
||||
f"✓ Sync complete: {indexed_count} documents indexed "
|
||||
f"(was {initial_indexed_count})"
|
||||
)
|
||||
return True, status_data
|
||||
|
||||
await anyio.sleep(wait_interval)
|
||||
waited += wait_interval
|
||||
|
||||
return False, status_data
|
||||
|
||||
|
||||
async def navigate_to_astrolabe_main(page: Page):
|
||||
"""Navigate to Astrolabe main app page (Semantic Search section).
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info("Navigating to Astrolabe main app...")
|
||||
await page.goto(f"{nextcloud_url}/apps/astrolabe", wait_until="networkidle")
|
||||
|
||||
# Wait for the app to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
logger.info("✓ Successfully loaded Astrolabe main app")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.timeout(
|
||||
300
|
||||
) # 5 minutes - this test involves OAuth, app password, and vector sync
|
||||
async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
browser,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test Plotly 3D visualization in Astrolabe with multi-user BasicAuth mode.
|
||||
|
||||
This test:
|
||||
1. Configures Astrolabe for the mcp-multi-user-basic service
|
||||
2. Provisions background sync access for alice via app password
|
||||
3. Creates a note with unique searchable content (as alice)
|
||||
4. Waits for vector sync to index the note
|
||||
5. Performs semantic search in Astrolabe UI
|
||||
6. Verifies the Plotly visualization renders and results are displayed
|
||||
"""
|
||||
# Phase 1: Configure Astrolabe for mcp-multi-user-basic
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
username = "alice"
|
||||
password = test_users_setup[username]["password"]
|
||||
note_id = None
|
||||
unique_term = None
|
||||
|
||||
# Create MCP client with alice's credentials for the multi-user BasicAuth server
|
||||
credentials = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8")
|
||||
auth_header = f"Basic {credentials}"
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Phase 2: Complete full Astrolabe authorization (OAuth + app password)
|
||||
await login_to_nextcloud(page, username, password)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
logger.info(f"Authorization result: {auth_result}")
|
||||
|
||||
# Create MCP client session as alice - all MCP operations inside this block
|
||||
async for alice_mcp_client in create_mcp_client_session(
|
||||
url="http://localhost:8003/mcp",
|
||||
headers={"Authorization": auth_header},
|
||||
client_name="Alice BasicAuth MCP",
|
||||
):
|
||||
# Phase 3: Get initial indexed count
|
||||
initial_sync = await alice_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", {}
|
||||
)
|
||||
|
||||
if initial_sync.isError:
|
||||
pytest.skip("Vector sync not enabled on mcp-multi-user-basic")
|
||||
|
||||
initial_data = json.loads(initial_sync.content[0].text)
|
||||
initial_count = initial_data.get("indexed_count", 0)
|
||||
logger.info(f"Initial indexed count: {initial_count}")
|
||||
|
||||
# Create note with unique searchable term
|
||||
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}"
|
||||
note_response = await alice_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{
|
||||
"title": f"Visualization Test Note {unique_term}",
|
||||
"content": f"""# Testing Plotly Visualization
|
||||
|
||||
This note contains the unique term: {unique_term}
|
||||
|
||||
It is used to test the 3D vector space visualization in the Astrolabe app.
|
||||
The visualization should show this document as a point in PCA-reduced space.
|
||||
|
||||
## Key Features
|
||||
- Semantic search with embeddings
|
||||
- PCA dimension reduction to 3D
|
||||
- Interactive Plotly scatter3d plot
|
||||
""",
|
||||
"category": "Test",
|
||||
},
|
||||
)
|
||||
|
||||
if note_response.isError:
|
||||
pytest.fail(f"Failed to create test note: {note_response}")
|
||||
|
||||
note_data = json.loads(note_response.content[0].text)
|
||||
note_id = note_data.get("id")
|
||||
logger.info(f"Created test note ID: {note_id}")
|
||||
|
||||
# Phase 4: Wait for vector indexing
|
||||
sync_complete, status = await wait_for_vector_sync(
|
||||
alice_mcp_client, initial_count, timeout_seconds=90
|
||||
)
|
||||
assert sync_complete, f"Vector sync did not complete in time: {status}"
|
||||
|
||||
# Phase 5: Navigate to Astrolabe and perform search
|
||||
await navigate_to_astrolabe_main(page)
|
||||
|
||||
# Fill search query - find the Astrolabe search input specifically
|
||||
# The NcTextField component wraps the input in a div with class mcp-search-input
|
||||
search_input = page.locator(".mcp-search-input input")
|
||||
await search_input.wait_for(timeout=10000, state="visible")
|
||||
await search_input.fill(unique_term)
|
||||
logger.info(f"Entered search query: {unique_term}")
|
||||
|
||||
# Trigger search by pressing Enter on the input field
|
||||
# This is wired to performSearch via @keyup.enter in the Vue component
|
||||
await search_input.press("Enter")
|
||||
logger.info("Pressed Enter to trigger search")
|
||||
|
||||
# Wait for loading to complete - watch for loading indicator to disappear
|
||||
loading_indicator = page.locator(".mcp-loading")
|
||||
try:
|
||||
# If loading indicator appears, wait for it to disappear
|
||||
if await loading_indicator.count() > 0:
|
||||
await loading_indicator.wait_for(state="hidden", timeout=30000)
|
||||
logger.info("Loading completed")
|
||||
except Exception:
|
||||
# Loading might be too fast to catch
|
||||
pass
|
||||
|
||||
# Brief wait for UI to settle
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Take diagnostic screenshot
|
||||
await page.screenshot(path="/tmp/astrolabe_search_after_click.png")
|
||||
logger.info(
|
||||
"Took diagnostic screenshot: /tmp/astrolabe_search_after_click.png"
|
||||
)
|
||||
|
||||
# Wait for search results using text-based detection
|
||||
# This is more reliable than class-based selectors
|
||||
# The UI shows "N results" when search completes successfully
|
||||
results_text_pattern = page.get_by_text(re.compile(r"\d+ results?"))
|
||||
no_results_text = page.get_by_text("No results found")
|
||||
error_note = page.locator(".mcp-error")
|
||||
|
||||
# Wait for one of: results count, no results message, or error
|
||||
try:
|
||||
# Poll for results or error states (don't rely on Nextcloud core CSS classes)
|
||||
found_state = False
|
||||
for attempt in range(60): # 60 attempts, 500ms each = 30s total
|
||||
if await error_note.count() > 0:
|
||||
error_text = await error_note.text_content()
|
||||
logger.error(f"Search error: {error_text}")
|
||||
pytest.fail(f"Search failed with error: {error_text}")
|
||||
|
||||
if await no_results_text.count() > 0:
|
||||
logger.warning(
|
||||
"No results found - vector sync may not have completed"
|
||||
)
|
||||
await page.screenshot(path="/tmp/astrolabe_no_results.png")
|
||||
pytest.fail(
|
||||
f"Search returned no results for '{unique_term}'. "
|
||||
"Check if vector sync completed for alice's content."
|
||||
)
|
||||
|
||||
if await results_text_pattern.count() > 0:
|
||||
results_text = await results_text_pattern.first.text_content()
|
||||
logger.info(f"Found results: {results_text}")
|
||||
found_state = True
|
||||
break
|
||||
|
||||
if attempt % 10 == 0:
|
||||
logger.info(
|
||||
f"Waiting for results... (attempt {attempt + 1}/60)"
|
||||
)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
if not found_state:
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
raise AssertionError("Search did not complete within timeout")
|
||||
|
||||
except AssertionError:
|
||||
raise # Re-raise AssertionError as-is
|
||||
except Exception as e:
|
||||
# Take another screenshot and get page content for debugging
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
raise AssertionError(f"Search did not complete: {e}")
|
||||
|
||||
logger.info("Results loaded")
|
||||
|
||||
# Phase 6: Verify visualization
|
||||
# Check Plotly container is visible
|
||||
viz_plot = page.locator("#viz-plot")
|
||||
await viz_plot.wait_for(timeout=15000, state="visible")
|
||||
logger.info("Plotly container is visible")
|
||||
|
||||
# Verify Plotly has rendered content (SVG/canvas elements inside)
|
||||
has_viz_content = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const plot = document.getElementById('viz-plot');
|
||||
if (!plot) return false;
|
||||
// Plotly creates .plotly class, canvas, or svg elements
|
||||
return plot.children.length > 0 ||
|
||||
plot.querySelector('.plotly, canvas, svg, .main-svg') !== null;
|
||||
}
|
||||
"""
|
||||
)
|
||||
assert has_viz_content, "Plotly visualization did not render any content"
|
||||
logger.info("✓ Plotly visualization rendered content")
|
||||
|
||||
# Verify results are displayed
|
||||
result_items = page.locator(".mcp-result-item")
|
||||
result_count = await result_items.count()
|
||||
assert result_count > 0, "No search results displayed"
|
||||
logger.info(f"✓ Found {result_count} search result(s)")
|
||||
|
||||
# Verify our note appears in results
|
||||
found_note = False
|
||||
for i in range(result_count):
|
||||
item = result_items.nth(i)
|
||||
title_elem = item.locator(".mcp-result-title")
|
||||
title_text = await title_elem.text_content()
|
||||
if title_text and unique_term in title_text:
|
||||
found_note = True
|
||||
logger.info(f"✓ Found test note in results: {title_text}")
|
||||
break
|
||||
|
||||
assert found_note, f"Created note with '{unique_term}' not found in results"
|
||||
|
||||
# Optional: Take screenshot for verification
|
||||
await page.screenshot(path="/tmp/astrolabe_plotly_test_success.png")
|
||||
logger.info("✓ All Plotly visualization assertions passed")
|
||||
|
||||
# Cleanup: delete the created note (inside the MCP client context)
|
||||
if note_id:
|
||||
try:
|
||||
delete_response = await alice_mcp_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id}")
|
||||
note_id = None # Mark as cleaned
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup note if not already cleaned (create new client for cleanup)
|
||||
if note_id:
|
||||
try:
|
||||
async for cleanup_client in create_mcp_client_session(
|
||||
url="http://localhost:8003/mcp",
|
||||
headers={"Authorization": auth_header},
|
||||
client_name="Cleanup MCP",
|
||||
):
|
||||
delete_response = await cleanup_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id} (finally)")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
|
||||
# Close browser context
|
||||
await context.close()
|
||||
@@ -18,8 +18,8 @@ from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api import management
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
from nextcloud_mcp_server.api import passwords
|
||||
from nextcloud_mcp_server.api.passwords import (
|
||||
delete_app_password,
|
||||
get_app_password_status,
|
||||
provision_app_password,
|
||||
@@ -32,9 +32,9 @@ pytestmark = pytest.mark.unit
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
"""Clear rate limit state before each test."""
|
||||
management._rate_limit_attempts.clear()
|
||||
passwords._rate_limit_attempts.clear()
|
||||
yield
|
||||
management._rate_limit_attempts.clear()
|
||||
passwords._rate_limit_attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -199,7 +199,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -243,7 +243,7 @@ async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -362,7 +362,7 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -406,7 +406,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -445,7 +445,7 @@ async def test_delete_app_password_invalid_credentials(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -515,7 +515,7 @@ async def test_provision_app_password_rate_limiting(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -574,7 +574,7 @@ async def test_rate_limiting_is_per_user(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
Unit tests for Management API PDF preview endpoint.
|
||||
|
||||
Tests the /api/v1/pdf-preview endpoint focusing on:
|
||||
- Parameter validation (file_path, page, scale)
|
||||
- OAuth token validation
|
||||
- PDF rendering with PyMuPDF
|
||||
- Error handling (file not found, invalid page, etc.)
|
||||
"""
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api.visualization import get_pdf_preview
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def create_test_app():
|
||||
"""Create a test Starlette app with the PDF preview endpoint."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]),
|
||||
]
|
||||
)
|
||||
# Set up OAuth context (required by endpoint)
|
||||
app.state.oauth_context = {"config": {"nextcloud_host": "http://localhost:8080"}}
|
||||
return app
|
||||
|
||||
|
||||
def create_mock_pdf_bytes():
|
||||
"""Create a minimal valid PDF for testing."""
|
||||
# Minimal PDF structure that PyMuPDF can parse
|
||||
# This is a 1-page PDF with a blank page
|
||||
pdf_content = b"""%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer
|
||||
<< /Size 4 /Root 1 0 R >>
|
||||
startxref
|
||||
196
|
||||
%%EOF"""
|
||||
return pdf_content
|
||||
|
||||
|
||||
class TestPdfPreviewParameterValidation:
|
||||
"""Tests for parameter validation in PDF preview endpoint."""
|
||||
|
||||
def test_missing_file_path_returns_400(self):
|
||||
"""Test that missing file_path parameter returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "file_path" in data["error"].lower()
|
||||
|
||||
def test_invalid_page_number_returns_400(self):
|
||||
"""Test that invalid page number (0 or negative) returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test page=0
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "page" in data["error"].lower()
|
||||
|
||||
# Test negative page
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=-1",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_scale_returns_400(self):
|
||||
"""Test that scale outside valid range returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test scale too small
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.1",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "scale" in data["error"].lower()
|
||||
|
||||
# Test scale too large
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=10.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_non_numeric_page_returns_400(self):
|
||||
"""Test that non-numeric page parameter returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=abc",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestPdfPreviewAuthentication:
|
||||
"""Tests for authentication in PDF preview endpoint."""
|
||||
|
||||
def test_unauthorized_without_token_returns_401(self):
|
||||
"""Test that request without token returns 401."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Invalid token"),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/pdf-preview?file_path=/test.pdf")
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_unauthorized_with_invalid_token_returns_401(self):
|
||||
"""Test that request with invalid token returns 401."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Token expired"),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestPdfPreviewRendering:
|
||||
"""Tests for PDF rendering functionality."""
|
||||
|
||||
def test_successful_pdf_render(self):
|
||||
"""Test successful PDF page rendering."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
# Mock the WebDAV client
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=1&scale=1.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "image" in data
|
||||
assert data["page_number"] == 1
|
||||
assert data["total_pages"] == 1
|
||||
|
||||
# Verify image is valid base64
|
||||
try:
|
||||
decoded = base64.b64decode(data["image"])
|
||||
# PNG magic bytes
|
||||
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
except Exception as e:
|
||||
pytest.fail(f"Image is not valid base64-encoded PNG: {e}")
|
||||
|
||||
def test_page_out_of_range_returns_400(self):
|
||||
"""Test that requesting page beyond total pages returns 400."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=999",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "page" in data["error"].lower()
|
||||
assert "999" in data["error"]
|
||||
|
||||
def test_file_not_found_returns_404(self):
|
||||
"""Test that non-existent file returns 404."""
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
side_effect=FileNotFoundError("File not found")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/nonexistent.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "not found" in data["error"].lower()
|
||||
|
||||
def test_default_parameters(self):
|
||||
"""Test that default parameters (page=1, scale=2.0) are used."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
# Only file_path, no page or scale
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["page_number"] == 1 # Default page
|
||||
|
||||
|
||||
class TestPdfPreviewEdgeCases:
|
||||
"""Tests for edge cases in PDF preview endpoint."""
|
||||
|
||||
def test_url_encoded_file_path(self):
|
||||
"""Test that URL-encoded file paths are handled correctly."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
# URL-encoded path with spaces
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/Documents/My%20File.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Verify the path was passed correctly to WebDAV
|
||||
mock_webdav.read_file.assert_called_once()
|
||||
call_args = mock_webdav.read_file.call_args[0]
|
||||
assert "My File.pdf" in call_args[0]
|
||||
|
||||
def test_missing_nextcloud_host_config(self):
|
||||
"""Test handling when Nextcloud host is not configured."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
# Override with empty config
|
||||
app.state.oauth_context = {"config": {"nextcloud_host": ""}}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_corrupted_pdf_returns_400(self):
|
||||
"""Test that corrupted PDF data returns 400 with specific error."""
|
||||
mock_webdav = AsyncMock()
|
||||
# Return invalid PDF bytes
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(b"not a valid pdf", "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert (
|
||||
"corrupted" in data["error"].lower()
|
||||
or "invalid" in data["error"].lower()
|
||||
)
|
||||
|
||||
def test_boundary_scale_values(self):
|
||||
"""Test boundary scale values (min and max)."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test minimum valid scale (0.5)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.5",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test maximum valid scale (5.0)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=5.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestPdfPreviewSecurityValidation:
|
||||
"""Tests for security validations in PDF preview endpoint."""
|
||||
|
||||
def test_path_traversal_returns_400(self):
|
||||
"""Test that path traversal attempts are blocked with 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test various path traversal patterns
|
||||
traversal_paths = [
|
||||
"/Documents/../../../etc/passwd",
|
||||
"/../secret.pdf",
|
||||
"/folder/..%2F..%2Fetc/passwd", # URL-encoded
|
||||
"/test/../secret.pdf",
|
||||
]
|
||||
|
||||
for path in traversal_paths:
|
||||
response = client.get(
|
||||
f"/api/v1/pdf-preview?file_path={path}",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400, (
|
||||
f"Path traversal not blocked: {path}"
|
||||
)
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "invalid file path" in data["error"].lower()
|
||||
|
||||
def test_file_size_limit_exceeded_returns_413(self):
|
||||
"""Test that files exceeding 50MB limit return 413."""
|
||||
# Create bytes larger than 50MB limit
|
||||
large_pdf_bytes = b"x" * (51 * 1024 * 1024) # 51 MB
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(large_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/large.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 413
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "size limit" in data["error"].lower()
|
||||
|
||||
def test_corrupted_pdf_returns_400(self):
|
||||
"""Test that corrupted PDF returns 400 with specific error message."""
|
||||
# Invalid PDF content that PyMuPDF cannot parse
|
||||
corrupted_pdf_bytes = b"not a valid PDF file content"
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(corrupted_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert (
|
||||
"corrupted" in data["error"].lower()
|
||||
or "invalid" in data["error"].lower()
|
||||
)
|
||||
|
||||
def test_empty_pdf_returns_400(self):
|
||||
"""Test that empty PDF file returns 400."""
|
||||
empty_pdf_bytes = b""
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(empty_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/empty.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.8.2"
|
||||
version = "0.10.1"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
Vendored
+54
@@ -25,6 +25,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Requires external MCP server deployment
|
||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
|
||||
## astrolabe-v0.10.1 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## astrolabe-v0.10.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## astrolabe-v0.9.0 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## astrolabe-v0.8.3 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## astrolabe-v0.8.2 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
+4
-1
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
||||
|
||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||
]]></description>
|
||||
<version>0.8.2</version>
|
||||
<version>0.10.1</version>
|
||||
<licence>agpl</licence>
|
||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||
<namespace>Astrolabe</namespace>
|
||||
@@ -57,4 +57,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
|
||||
<type>link</type>
|
||||
</navigation>
|
||||
</navigations>
|
||||
<background-jobs>
|
||||
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
||||
</background-jobs>
|
||||
</info>
|
||||
|
||||
+5
@@ -72,6 +72,11 @@ return [
|
||||
'url' => '/api/chunk-context',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#pdfPreview',
|
||||
'url' => '/api/pdf-preview',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
|
||||
// Admin settings routes
|
||||
[
|
||||
|
||||
Vendored
+1
@@ -39,6 +39,7 @@
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^3.8",
|
||||
"nextcloud/ocp": "dev-stable30",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
|
||||
+316
-14
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "94a9d7f7619235ef2a310deec2ce14f0",
|
||||
"content-hash": "e6ea5a770c578a5d7694602bb2618cef",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bamarni/composer-bin-plugin",
|
||||
@@ -65,6 +65,259 @@
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
"version": "3.10.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/dbal.git",
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2",
|
||||
"doctrine/deprecations": "^0.5.3|^1",
|
||||
"doctrine/event-manager": "^1|^2",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"psr/cache": "^1|^2|^3",
|
||||
"psr/log": "^1|^2|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/cache": "< 1.11"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/cache": "^1.11|^2.0",
|
||||
"doctrine/coding-standard": "14.0.0",
|
||||
"fig/log-test": "^1",
|
||||
"jetbrains/phpstorm-stubs": "2023.1",
|
||||
"phpstan/phpstan": "2.1.30",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "9.6.29",
|
||||
"slevomat/coding-standard": "8.24.0",
|
||||
"squizlabs/php_codesniffer": "4.0.0",
|
||||
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/console": "For helpful console commands such as SQL execution and import of files."
|
||||
},
|
||||
"bin": [
|
||||
"bin/doctrine-dbal"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\DBAL\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
|
||||
"keywords": [
|
||||
"abstraction",
|
||||
"database",
|
||||
"db2",
|
||||
"dbal",
|
||||
"mariadb",
|
||||
"mssql",
|
||||
"mysql",
|
||||
"oci8",
|
||||
"oracle",
|
||||
"pdo",
|
||||
"pgsql",
|
||||
"postgresql",
|
||||
"queryobject",
|
||||
"sasql",
|
||||
"sql",
|
||||
"sqlite",
|
||||
"sqlserver",
|
||||
"sqlsrv"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/dbal/issues",
|
||||
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-29T10:46:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/deprecations.git",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<=7.5 || >=13"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^13",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.11",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
|
||||
"psr/log": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Deprecations\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
|
||||
"homepage": "https://www.doctrine-project.org/",
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.5"
|
||||
},
|
||||
"time": "2025-04-07T20:06:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/event-manager",
|
||||
"version": "2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/event-manager.git",
|
||||
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312",
|
||||
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/common": "<2.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^14",
|
||||
"phpdocumentor/guides-cli": "^1.4",
|
||||
"phpstan/phpstan": "^2.1.32",
|
||||
"phpunit/phpunit": "^10.5.58"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Common\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Johannes Schmitt",
|
||||
"email": "schmittjoh@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Marco Pivetta",
|
||||
"email": "ocramius@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
|
||||
"keywords": [
|
||||
"event",
|
||||
"event dispatcher",
|
||||
"event manager",
|
||||
"event system",
|
||||
"events"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/event-manager/issues",
|
||||
"source": "https://github.com/doctrine/event-manager/tree/2.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-17T22:40:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
"version": "1.13.4",
|
||||
@@ -668,16 +921,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "10.5.60",
|
||||
"version": "10.5.63",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c"
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -698,7 +951,7 @@
|
||||
"phpunit/php-timer": "^6.0.0",
|
||||
"sebastian/cli-parser": "^2.0.1",
|
||||
"sebastian/code-unit": "^2.0.0",
|
||||
"sebastian/comparator": "^5.0.4",
|
||||
"sebastian/comparator": "^5.0.5",
|
||||
"sebastian/diff": "^5.1.1",
|
||||
"sebastian/environment": "^6.1.0",
|
||||
"sebastian/exporter": "^5.1.4",
|
||||
@@ -749,7 +1002,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -773,7 +1026,56 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-06T07:50:42+00:00"
|
||||
"time": "2026-01-27T05:48:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/cache.git",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Cache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for caching libraries",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"psr",
|
||||
"psr-6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/cache/tree/3.0.0"
|
||||
},
|
||||
"time": "2021-02-03T23:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
@@ -2150,16 +2452,16 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
"version": "5.0.4",
|
||||
"version": "5.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e"
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2215,7 +2517,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4"
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2235,7 +2537,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-07T05:25:07+00:00"
|
||||
"time": "2026-01-24T09:25:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/complexity",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\Lock\LockedException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Background job to proactively refresh OAuth tokens before expiration.
|
||||
*
|
||||
* Runs every 15 minutes and refreshes tokens based on their actual expiration
|
||||
* time. Works with any IdP (Nextcloud OIDC, Keycloak, etc.) since it uses
|
||||
* the real token expiration rather than IdP configuration.
|
||||
*
|
||||
* Refresh strategy: Refresh when less than 50% of token lifetime remains,
|
||||
* ensuring tokens are refreshed well before expiration regardless of the
|
||||
* IdP's configured token lifetime.
|
||||
*
|
||||
* @psalm-suppress UnusedClass - Background jobs are loaded dynamically by Nextcloud
|
||||
*/
|
||||
class RefreshUserTokens extends TimedJob {
|
||||
/** Job runs every 15 minutes */
|
||||
private const JOB_INTERVAL_SECONDS = 900;
|
||||
|
||||
/** Refresh when this percentage of token lifetime remains */
|
||||
private const REFRESH_AT_REMAINING_PERCENT = 0.5;
|
||||
|
||||
/** Minimum threshold to avoid constant refresh (5 minutes) */
|
||||
private const MIN_THRESHOLD_SECONDS = 300;
|
||||
|
||||
/** Default assumed token lifetime if we can't determine it (1 hour) */
|
||||
private const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
|
||||
|
||||
/** Batch size for processing users (prevents memory issues on large installations) */
|
||||
private const BATCH_SIZE = 100;
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
$this->setInterval(self::JOB_INTERVAL_SECONDS);
|
||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||
}
|
||||
|
||||
protected function run(mixed $argument): void {
|
||||
$this->logger->info('RefreshUserTokens: Starting background token refresh');
|
||||
|
||||
$refreshed = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$offset = 0;
|
||||
$totalUsers = 0;
|
||||
|
||||
// Process users in batches to prevent memory issues on large installations
|
||||
do {
|
||||
$userIds = $this->tokenStorage->getAllUsersWithTokens(self::BATCH_SIZE, $offset);
|
||||
$batchCount = count($userIds);
|
||||
$totalUsers += $batchCount;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$result = $this->refreshUserTokenIfNeeded($userId);
|
||||
match ($result) {
|
||||
'refreshed' => $refreshed++,
|
||||
'failed' => $failed++,
|
||||
'skipped' => $skipped++,
|
||||
};
|
||||
}
|
||||
|
||||
$offset += self::BATCH_SIZE;
|
||||
} while ($batchCount === self::BATCH_SIZE);
|
||||
|
||||
$this->logger->info("RefreshUserTokens: Complete - total=$totalUsers, refreshed=$refreshed, failed=$failed, skipped=$skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user's token if it's nearing expiration.
|
||||
*
|
||||
* Calculates the refresh threshold based on the token's actual lifetime,
|
||||
* refreshing when less than 50% of the lifetime remains.
|
||||
*
|
||||
* Uses locking to prevent race conditions with on-demand refresh in
|
||||
* getAccessToken(). If lock cannot be acquired, skips this user since
|
||||
* on-demand refresh is already handling it.
|
||||
*
|
||||
* @return string 'refreshed', 'failed', or 'skipped'
|
||||
*/
|
||||
private function refreshUserTokenIfNeeded(string $userId): string {
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($token === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
$expiresAt = (int)($token['expires_at'] ?? 0);
|
||||
$issuedAt = isset($token['issued_at']) ? (int)$token['issued_at'] : null;
|
||||
$timeRemaining = $expiresAt - time();
|
||||
|
||||
// Calculate token lifetime from stored data or use default
|
||||
if ($issuedAt !== null) {
|
||||
$tokenLifetime = $expiresAt - $issuedAt;
|
||||
} else {
|
||||
// Fallback: use default lifetime assumption
|
||||
$tokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
// Calculate threshold: refresh when 50% of lifetime remains
|
||||
$threshold = max(
|
||||
(int)($tokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($timeRemaining > $threshold) {
|
||||
// Token still has plenty of time, skip
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Token is expiring soon, attempt refresh with lock
|
||||
try {
|
||||
return $this->tokenStorage->withTokenLock($userId, function () use ($userId) {
|
||||
// Re-check token after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for lock
|
||||
$currentToken = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($currentToken === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Recalculate threshold with current token data
|
||||
$currentExpiresAt = (int)($currentToken['expires_at'] ?? 0);
|
||||
$currentIssuedAt = isset($currentToken['issued_at']) ? (int)$currentToken['issued_at'] : null;
|
||||
$currentTimeRemaining = $currentExpiresAt - time();
|
||||
|
||||
if ($currentIssuedAt !== null) {
|
||||
$currentTokenLifetime = $currentExpiresAt - $currentIssuedAt;
|
||||
} else {
|
||||
$currentTokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
$currentThreshold = max(
|
||||
(int)($currentTokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($currentTimeRemaining > $currentThreshold) {
|
||||
// Token was refreshed by another process while we waited
|
||||
$this->logger->debug("RefreshUserTokens: Token already refreshed for user $userId while waiting for lock");
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Still needs refresh, proceed
|
||||
if (!isset($currentToken['refresh_token'])) {
|
||||
$this->logger->warning("RefreshUserTokens: User $userId has no refresh token");
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Refreshing token for user $userId (remaining={$currentTimeRemaining}s, threshold={$currentThreshold}s)");
|
||||
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
$this->logger->warning("RefreshUserTokens: Refresh returned null for user $userId");
|
||||
// Don't delete token here - let on-demand refresh handle cleanup
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// Calculate new expiration and store issued_at for future calculations
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? self::DEFAULT_TOKEN_LIFETIME_SECONDS);
|
||||
$now = time();
|
||||
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
|
||||
$this->tokenStorage->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at
|
||||
);
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Successfully refreshed token for user $userId");
|
||||
return 'refreshed';
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Lock held by on-demand refresh - expected, not an error
|
||||
$this->logger->debug("RefreshUserTokens: Lock held for user $userId, skipping");
|
||||
return 'skipped';
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("RefreshUserTokens: Failed to refresh for user $userId: " . $e->getMessage());
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-15
@@ -152,10 +152,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback that calls IdP directly
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -168,7 +169,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required. Please authorize the app first.'
|
||||
@@ -417,10 +418,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -433,7 +435,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
@@ -529,10 +531,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -545,7 +548,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
@@ -628,10 +631,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -644,7 +648,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
@@ -757,10 +761,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -773,7 +778,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required.'
|
||||
@@ -788,4 +793,62 @@ class ApiController extends Controller {
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF page preview (server-side rendered).
|
||||
*
|
||||
* AJAX endpoint for PDF viewer in semantic search UI.
|
||||
* Uses server-side PyMuPDF rendering to avoid CSP/worker issues.
|
||||
*
|
||||
* @param string $file_path WebDAV path to PDF file
|
||||
* @param int $page Page number (1-indexed, default: 1)
|
||||
* @param float $scale Zoom factor (default: 2.0)
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function pdfPreview(
|
||||
string $file_path,
|
||||
int $page = 1,
|
||||
float $scale = 2.0,
|
||||
): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['success' => false, 'error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$result = $this->client->getPdfPreview($file_path, $page, $scale, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface;
|
||||
* - Public clients: PKCE only
|
||||
* - Confidential clients: PKCE + client_secret (defense in depth)
|
||||
*/
|
||||
class OAuthController extends Controller {
|
||||
class OauthController extends Controller {
|
||||
private IConfig $config;
|
||||
private ISession $session;
|
||||
private IUserSession $userSession;
|
||||
+23
-3
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Search;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||
@@ -35,6 +36,7 @@ class SemanticSearchProvider implements IProvider {
|
||||
public function __construct(
|
||||
private McpServerClient $client,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private IConfig $config,
|
||||
private IL10N $l10n,
|
||||
private IURLGenerator $urlGenerator,
|
||||
@@ -85,12 +87,30 @@ class SemanticSearchProvider implements IProvider {
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Get OAuth token for user
|
||||
$accessToken = $this->tokenStorage->getAccessToken($user->getUID());
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback matching ApiController pattern
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get OAuth token for user with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
// User hasn't authorized the app yet - return empty results
|
||||
$this->logger->debug('No OAuth token for user in semantic search', [
|
||||
'user_id' => $user->getUID(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
@@ -605,4 +605,62 @@ class McpServerClient {
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF page preview (server-side rendered).
|
||||
*
|
||||
* Renders a PDF page to PNG using PyMuPDF on the server.
|
||||
* This avoids client-side PDF.js issues with CSP and ES private fields.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $filePath WebDAV path to PDF file
|
||||
* @param int $page Page number (1-indexed)
|
||||
* @param float $scale Zoom factor (default: 2.0)
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* success?: bool,
|
||||
* image?: string,
|
||||
* page_number?: int,
|
||||
* total_pages?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getPdfPreview(
|
||||
string $filePath,
|
||||
int $page,
|
||||
float $scale,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/pdf-preview',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
],
|
||||
'query' => [
|
||||
'file_path' => $filePath,
|
||||
'page' => $page,
|
||||
'scale' => $scale,
|
||||
]
|
||||
]
|
||||
);
|
||||
/** @var array{success?: bool, image?: string, page_number?: int, total_pages?: int, error?: string} $data */
|
||||
$data = json_decode((string)$response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get PDF preview', [
|
||||
'error' => $e->getMessage(),
|
||||
'file_path' => $filePath,
|
||||
'page' => $page,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+164
-33
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ICrypto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -20,16 +23,22 @@ class McpTokenStorage {
|
||||
|
||||
private $config;
|
||||
private $crypto;
|
||||
private $db;
|
||||
private $logger;
|
||||
private ILockingProvider $lockingProvider;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
ICrypto $crypto,
|
||||
IDBConnection $db,
|
||||
LoggerInterface $logger,
|
||||
ILockingProvider $lockingProvider,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->crypto = $crypto;
|
||||
$this->db = $db;
|
||||
$this->logger = $logger;
|
||||
$this->lockingProvider = $lockingProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,18 +50,21 @@ class McpTokenStorage {
|
||||
* @param string $accessToken OAuth access token
|
||||
* @param string $refreshToken OAuth refresh token
|
||||
* @param int $expiresAt Unix timestamp when token expires
|
||||
* @param int|null $issuedAt Unix timestamp when token was issued (for lifetime calculation)
|
||||
*/
|
||||
public function storeUserToken(
|
||||
string $userId,
|
||||
string $accessToken,
|
||||
string $refreshToken,
|
||||
int $expiresAt,
|
||||
?int $issuedAt = null,
|
||||
): void {
|
||||
try {
|
||||
$tokenData = [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt,
|
||||
'issued_at' => $issuedAt ?? time(),
|
||||
];
|
||||
|
||||
// Encrypt token data before storage
|
||||
@@ -129,6 +141,42 @@ class McpTokenStorage {
|
||||
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lock path for a user's token refresh operation.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string Lock path
|
||||
*/
|
||||
private function getTokenRefreshLockPath(string $userId): string {
|
||||
return 'astrolabe/oauth/tokens/' . $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback while holding exclusive lock on user's token.
|
||||
*
|
||||
* Prevents race conditions between background job and on-demand token refresh.
|
||||
*
|
||||
* Note: Lock TTL is configured at the Nextcloud server level (default: 3600s).
|
||||
* If a process crashes while holding the lock, it will auto-expire after the TTL.
|
||||
* The ILockingProvider interface does not support per-call timeouts.
|
||||
*
|
||||
* @template T
|
||||
* @param string $userId User ID
|
||||
* @param callable(): T $callback
|
||||
* @return T
|
||||
* @throws LockedException If lock cannot be acquired
|
||||
*/
|
||||
public function withTokenLock(string $userId, callable $callback): mixed {
|
||||
$lockPath = $this->getTokenRefreshLockPath($userId);
|
||||
|
||||
$this->lockingProvider->acquireLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->lockingProvider->releaseLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored tokens for a user.
|
||||
*
|
||||
@@ -153,65 +201,148 @@ class McpTokenStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs that have OAuth tokens stored.
|
||||
*
|
||||
* Queries oc_preferences directly since IConfig doesn't support
|
||||
* listing all users with a specific key set.
|
||||
*
|
||||
* @param int $limit Maximum users to return (0 = no limit, for backward compatibility)
|
||||
* @param int $offset Starting offset for pagination
|
||||
* @return list<string> Array of user IDs
|
||||
*/
|
||||
public function getAllUsersWithTokens(int $limit = 0, int $offset = 0): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('userid')
|
||||
->from('preferences')
|
||||
->where($qb->expr()->eq('appid', $qb->createNamedParameter('astrolabe')))
|
||||
->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('oauth_tokens')));
|
||||
|
||||
if ($limit > 0) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset > 0) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
/** @var list<string> $userIds */
|
||||
$userIds = [];
|
||||
/** @psalm-suppress MixedAssignment - IResult::fetch() returns mixed */
|
||||
while (($row = $result->fetch()) !== false) {
|
||||
if (is_array($row) && isset($row['userid']) && is_string($row['userid'])) {
|
||||
$userIds[] = $row['userid'];
|
||||
}
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token for a user, handling expiration and refresh.
|
||||
*
|
||||
* This is a convenience method that combines token retrieval,
|
||||
* expiration checking, and automatic refresh if needed.
|
||||
*
|
||||
* Uses double-check locking pattern to prevent race conditions between
|
||||
* background job and on-demand refresh while minimizing lock contention.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param callable|null $refreshCallback Callback to refresh token if expired
|
||||
* Should accept (refreshToken) and return new token data
|
||||
* @return string|null Access token, or null if not available
|
||||
*/
|
||||
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
||||
// Quick check without lock (optimization)
|
||||
$token = $this->getUserToken($userId);
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if ($this->isExpired($token)) {
|
||||
// Try to refresh if callback provided
|
||||
if ($refreshCallback && isset($token['refresh_token'])) {
|
||||
try {
|
||||
$newTokenData = $refreshCallback($token['refresh_token']);
|
||||
// If not expired, return immediately without lock
|
||||
if (!$this->isExpired($token)) {
|
||||
return $token['access_token'];
|
||||
}
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$newTokenData['access_token'],
|
||||
$newTokenData['refresh_token'] ?? $token['refresh_token'],
|
||||
time() + ($newTokenData['expires_in'] ?? 3600)
|
||||
);
|
||||
// Token expired - acquire lock for refresh
|
||||
try {
|
||||
/**
|
||||
* @return string|null
|
||||
* @psalm-suppress MixedInferredReturnType
|
||||
*/
|
||||
return $this->withTokenLock($userId, function () use ($userId, $refreshCallback): ?string {
|
||||
// Re-check after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for the lock
|
||||
$currentToken = $this->getUserToken($userId);
|
||||
|
||||
return $newTokenData['access_token'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Delete stale token to prevent repeated refresh attempts
|
||||
$this->deleteUserToken($userId);
|
||||
if ($currentToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh callback returned null or invalid data - delete stale token
|
||||
// Check if another process already refreshed the token
|
||||
if (!$this->isExpired($currentToken)) {
|
||||
$this->logger->debug("Token already refreshed for user $userId while waiting for lock");
|
||||
/** @var string */
|
||||
return $currentToken['access_token'];
|
||||
}
|
||||
|
||||
// Still expired, perform refresh
|
||||
if ($refreshCallback && isset($currentToken['refresh_token'])) {
|
||||
try {
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $refreshCallback($refreshToken);
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$now = time();
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? 3600);
|
||||
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at for accurate lifetime calculation
|
||||
);
|
||||
|
||||
return $accessToken;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Delete stale token to prevent repeated refresh attempts
|
||||
$this->deleteUserToken($userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh callback returned null or invalid data - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expired and no refresh callback available - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expired and no refresh callback available - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Could not acquire lock - another process is refreshing
|
||||
// Return stale token rather than failing - caller can retry if needed
|
||||
$this->logger->warning("Could not acquire token lock for user $userId, returning stale token");
|
||||
/** @var string|null $staleToken */
|
||||
$staleToken = $token['access_token'] ?? null;
|
||||
return $staleToken;
|
||||
}
|
||||
|
||||
return $token['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -86,7 +86,9 @@ class Personal implements ISettings {
|
||||
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
|
||||
// Check both credentials
|
||||
$hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token));
|
||||
$hasAppPassword = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
// In hybrid mode, check specifically for app password (not general background access)
|
||||
// because MCP server needs the app password for background sync
|
||||
$hasAppPassword = ($this->tokenStorage->getBackgroundSyncPassword($userId) !== null);
|
||||
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
|
||||
+70
-256
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.8.2",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astrolabe",
|
||||
"version": "0.8.2",
|
||||
"version": "0.10.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@nextcloud/axios": "^2.5.1",
|
||||
@@ -16,8 +16,7 @@
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"plotly.js-dist-min": "^2.35.3",
|
||||
"plotly.js-dist-min": "^3.0.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
@@ -1191,185 +1190,6 @@
|
||||
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.84",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.84",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.84",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.84",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.84",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.84"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.84.tgz",
|
||||
"integrity": "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.84.tgz",
|
||||
"integrity": "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.84.tgz",
|
||||
"integrity": "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.84.tgz",
|
||||
"integrity": "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.84.tgz",
|
||||
"integrity": "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.84.tgz",
|
||||
"integrity": "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.84.tgz",
|
||||
"integrity": "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.84",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.84.tgz",
|
||||
"integrity": "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -1657,9 +1477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nextcloud/vue": {
|
||||
"version": "9.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.3.tgz",
|
||||
"integrity": "sha512-M/M4L9vp1AJQ8RRk75mbMwUo7sOwWDaTDmAwgpTa9LARDe5e6UBJoMhOmiz5EPkYRHLn2SLE+baOIXVmtVMdqw==",
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.4.0.tgz",
|
||||
"integrity": "sha512-MoEbaFqFeZfTB+8d/BtgObAfzJMQ+vdidzMP/zKzx9J4cW+vgY5bciDUueY+t3f0uwSJXO3xsqXXWj9x2KihzQ==",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ckpack/vue-color": "^1.6.0",
|
||||
@@ -1684,7 +1504,7 @@
|
||||
"emoji-mart-vue-fast": "^15.0.5",
|
||||
"escape-html": "^1.0.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"focus-trap": "7.6.6",
|
||||
"focus-trap": "^7.8.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"p-queue": "^9.1.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
@@ -3003,22 +2823,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
|
||||
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
|
||||
"integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/shared": "3.5.27",
|
||||
"entities": "^7.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core/node_modules/entities": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
|
||||
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -3028,26 +2848,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
|
||||
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
||||
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz",
|
||||
"integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-ssr": "3.5.26",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -3055,13 +2875,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
|
||||
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz",
|
||||
"integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
@@ -3095,53 +2915,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
||||
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz",
|
||||
"integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
|
||||
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz",
|
||||
"integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
|
||||
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.26",
|
||||
"@vue/runtime-core": "3.5.26",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/runtime-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
|
||||
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz",
|
||||
"integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.26"
|
||||
"vue": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
|
||||
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz",
|
||||
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vuepic/vue-datepicker": {
|
||||
@@ -5352,10 +5172,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.6.6",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tabbable": "^6.3.0"
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
@@ -7879,16 +7701,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "4.10.38",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.65"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"license": "ISC"
|
||||
@@ -7905,7 +7717,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/plotly.js-dist-min": {
|
||||
"version": "2.35.3",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-3.3.1.tgz",
|
||||
"integrity": "sha512-ZxKM9DlEoEF3wBzGRPGHt6gWTJrm5N81J9AgX9UBX/Qjc9L4lRxtPBPq+RmBJWoA71j1X5Z1ouuguLkdoo88tg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
@@ -10297,16 +10111,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
||||
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
"@vue/runtime-dom": "3.5.26",
|
||||
"@vue/server-renderer": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-sfc": "3.5.27",
|
||||
"@vue/runtime-dom": "3.5.27",
|
||||
"@vue/server-renderer": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
||||
Vendored
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.8.2",
|
||||
"version": "0.10.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
@@ -25,8 +25,7 @@
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"plotly.js-dist-min": "^2.35.3",
|
||||
"plotly.js-dist-min": "^3.0.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
|
||||
+2
-26
@@ -13,13 +13,6 @@
|
||||
<code><![CDATA[$result['query_coords']]]></code>
|
||||
<code><![CDATA[$webhook['eventFilter']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MissingClosureReturnType>
|
||||
<code><![CDATA[function (string $refreshToken) {]]></code>
|
||||
<code><![CDATA[function (string $refreshToken) {]]></code>
|
||||
<code><![CDATA[function (string $refreshToken) {]]></code>
|
||||
<code><![CDATA[function (string $refreshToken) {]]></code>
|
||||
<code><![CDATA[function (string $refreshToken) {]]></code>
|
||||
</MissingClosureReturnType>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[!empty($eventConfig['filter']) ? $eventConfig['filter'] : null]]></code>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
@@ -62,16 +55,6 @@
|
||||
<code><![CDATA[$webhook['id']]]></code>
|
||||
</PossiblyUndefinedArrayOffset>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[!$accessToken]]></code>
|
||||
<code><![CDATA[!$accessToken]]></code>
|
||||
<code><![CDATA[!$accessToken]]></code>
|
||||
<code><![CDATA[!$accessToken]]></code>
|
||||
<code><![CDATA[!$accessToken]]></code>
|
||||
<code><![CDATA[!$newTokenData]]></code>
|
||||
<code><![CDATA[!$newTokenData]]></code>
|
||||
<code><![CDATA[!$newTokenData]]></code>
|
||||
<code><![CDATA[!$newTokenData]]></code>
|
||||
<code><![CDATA[!$newTokenData]]></code>
|
||||
<code><![CDATA[!$token]]></code>
|
||||
<code><![CDATA[empty($webhook['eventFilter'])]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
@@ -106,7 +89,7 @@
|
||||
<code><![CDATA[CredentialsController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Controller/OAuthController.php">
|
||||
<file src="lib/Controller/OauthController.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$authEndpoint]]></code>
|
||||
<code><![CDATA[$codeVerifier]]></code>
|
||||
@@ -175,7 +158,7 @@
|
||||
<code><![CDATA[$error]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[OAuthController]]></code>
|
||||
<code><![CDATA[OauthController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Listener/AstrolabeAdminSettingsListener.php">
|
||||
@@ -405,11 +388,6 @@
|
||||
<InvalidReturnType>
|
||||
<code><![CDATA[array|null]]></code>
|
||||
</InvalidReturnType>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$newTokenData['access_token']]]></code>
|
||||
<code><![CDATA[$newTokenData['refresh_token'] ?? $token['refresh_token']]]></code>
|
||||
<code><![CDATA[time() + ($newTokenData['expires_in'] ?? 3600)]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$newTokenData]]></code>
|
||||
</MixedAssignment>
|
||||
@@ -417,11 +395,9 @@
|
||||
<code><![CDATA[string|null]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$newTokenData['expires_in'] ?? 3600]]></code>
|
||||
<code><![CDATA[$token['expires_at']]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$newTokenData['access_token']]]></code>
|
||||
<code><![CDATA[$token['access_token']]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyUnusedMethod>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 736 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 218 KiB |
Vendored
+27
-19
@@ -394,18 +394,6 @@ import MarkdownViewer from './components/MarkdownViewer.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import Plotly from 'plotly.js-dist-min'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
|
||||
// Set worker source with error handling
|
||||
try {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url,
|
||||
).toString()
|
||||
} catch (e) {
|
||||
console.warn('Failed to set PDF.js worker, will use fallback', e)
|
||||
// PDF.js will use fake worker automatically
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -615,7 +603,20 @@ export default {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search error:', err)
|
||||
this.error = this.t('astrolabe', 'Network error. Please try again.')
|
||||
// Check if this is an HTTP error with a response
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
// Use the specific error message from the backend
|
||||
this.error = err.response.data.error
|
||||
} else if (err.response && err.response.status === 401) {
|
||||
// Unauthorized - user needs to authorize the app
|
||||
this.error = this.t('astrolabe', 'Authorization required. Please complete Step 1 in Settings → Astrolabe.')
|
||||
} else if (err.response && err.response.status === 503) {
|
||||
// Service unavailable - MCP server not reachable
|
||||
this.error = this.t('astrolabe', 'Search service unavailable. Please try again later.')
|
||||
} else {
|
||||
// Actual network error or unknown error
|
||||
this.error = this.t('astrolabe', 'Network error. Please try again.')
|
||||
}
|
||||
this.results = []
|
||||
} finally {
|
||||
this.loading = false
|
||||
@@ -637,7 +638,14 @@ export default {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Status error:', err)
|
||||
this.statusError = this.t('astrolabe', 'Network error. Please try again.')
|
||||
// Extract error message from response if available
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
this.statusError = err.response.data.error
|
||||
} else if (err.response && err.response.status === 401) {
|
||||
this.statusError = this.t('astrolabe', 'Authorization required. Please complete Step 1 in Settings → Astrolabe.')
|
||||
} else {
|
||||
this.statusError = this.t('astrolabe', 'Network error. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
this.statusLoading = false
|
||||
}
|
||||
@@ -749,7 +757,7 @@ export default {
|
||||
colorscale: 'Viridis',
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: 'Relative Score',
|
||||
title: { text: 'Relative Score' },
|
||||
x: 1.02,
|
||||
xanchor: 'left',
|
||||
thickness: 20,
|
||||
@@ -784,13 +792,13 @@ export default {
|
||||
}
|
||||
|
||||
const layout = {
|
||||
title: `Vector Space (PCA 3D) - ${results.length} results`,
|
||||
title: { text: `Vector Space (PCA 3D) - ${results.length} results` },
|
||||
width,
|
||||
height,
|
||||
scene: {
|
||||
xaxis: { title: 'PC1' },
|
||||
yaxis: { title: 'PC2' },
|
||||
zaxis: { title: 'PC3' },
|
||||
xaxis: { title: { text: 'PC1' } },
|
||||
yaxis: { title: { text: 'PC2' } },
|
||||
zaxis: { title: { text: 'PC3' } },
|
||||
camera: {
|
||||
eye: { x: 1.5, y: 1.5, z: 1.5 },
|
||||
},
|
||||
|
||||
+65
-111
@@ -8,15 +8,28 @@
|
||||
<AlertCircle :size="48" />
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<div v-else ref="containerRef" class="pdf-canvas-container">
|
||||
<canvas ref="canvasRef" />
|
||||
<div v-else class="pdf-image-container">
|
||||
<img
|
||||
:src="`data:image/png;base64,${imageData}`"
|
||||
class="pdf-page-image"
|
||||
alt="PDF page" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
/**
|
||||
* PDFViewer - Server-side PDF rendering component.
|
||||
*
|
||||
* Displays PDF pages as server-rendered PNG images, avoiding client-side
|
||||
* PDF.js issues with CSP worker restrictions and ES private field access
|
||||
* in Chromium browsers.
|
||||
*
|
||||
* The server uses PyMuPDF to render PDF pages to PNG images, which are
|
||||
* returned as base64-encoded data.
|
||||
*/
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
@@ -33,61 +46,68 @@ const props = defineProps({
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1.5,
|
||||
default: 2.0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
|
||||
|
||||
// Reactive state
|
||||
const pdfDoc = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const imageData = ref(null)
|
||||
const totalPages = ref(0)
|
||||
const canvasRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
|
||||
// Methods
|
||||
async function loadPDF() {
|
||||
/**
|
||||
* Fetch a PDF page from the server as a PNG image.
|
||||
*/
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Clean and encode the file path
|
||||
const cleanPath = props.filePath.startsWith('/')
|
||||
? props.filePath.substring(1)
|
||||
: props.filePath
|
||||
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
|
||||
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
|
||||
// Build request URL
|
||||
const url = generateUrl('/apps/astrolabe/api/pdf-preview')
|
||||
const params = {
|
||||
file_path: props.filePath,
|
||||
page: props.pageNumber,
|
||||
scale: props.scale,
|
||||
}
|
||||
|
||||
// Load PDF document
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
url: downloadUrl,
|
||||
withCredentials: true,
|
||||
useWorkerFetch: false, // Disable worker fetch for CSP compliance
|
||||
isEvalSupported: false, // Disable eval for CSP
|
||||
})
|
||||
const response = await axios.get(url, { params })
|
||||
|
||||
pdfDoc.value = await loadingTask.promise
|
||||
totalPages.value = pdfDoc.value.numPages
|
||||
emit('loaded', { totalPages: totalPages.value })
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Failed to load PDF page')
|
||||
}
|
||||
|
||||
const data = response.data
|
||||
|
||||
// Update state
|
||||
imageData.value = data.image
|
||||
totalPages.value = data.total_pages
|
||||
|
||||
// Emit loaded event - App.vue uses this for navigation controls
|
||||
emit('loaded', { totalPages: data.total_pages })
|
||||
emit('page-rendered', { pageNumber: props.pageNumber })
|
||||
|
||||
// Set loading to false - the watcher will handle rendering
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
console.error('PDF load error:', err)
|
||||
|
||||
// Provide user-friendly error messages
|
||||
if (err.name === 'MissingPDFException') {
|
||||
// Provide user-friendly error messages based on axios error structure
|
||||
const status = err.response?.status
|
||||
const serverError = err.response?.data?.error
|
||||
|
||||
if (status === 404) {
|
||||
error.value = t('astrolabe', 'PDF file not found')
|
||||
} else if (err.name === 'InvalidPDFException') {
|
||||
error.value = t('astrolabe', 'Invalid or corrupted PDF file')
|
||||
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
|
||||
} else if (status === 401 || status === 403) {
|
||||
error.value = serverError || t('astrolabe', 'Authorization required to view PDF')
|
||||
} else if (err.code === 'ERR_NETWORK' || err.message?.includes('Network')) {
|
||||
error.value = t('astrolabe', 'Network error loading PDF')
|
||||
} else if (err.message?.includes('404')) {
|
||||
error.value = t('astrolabe', 'PDF file not found')
|
||||
} else if (serverError) {
|
||||
error.value = serverError
|
||||
} else {
|
||||
error.value = t('astrolabe', 'Unable to load PDF file')
|
||||
error.value = t('astrolabe', 'Unable to load PDF page')
|
||||
}
|
||||
|
||||
emit('error', err)
|
||||
@@ -95,78 +115,12 @@ async function loadPDF() {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(pageNum) {
|
||||
if (!pdfDoc.value) {
|
||||
return
|
||||
}
|
||||
// Re-fetch when file path or page number changes
|
||||
watch(() => [props.filePath, props.pageNumber], loadPage)
|
||||
|
||||
try {
|
||||
const page = await pdfDoc.value.getPage(pageNum)
|
||||
const canvas = canvasRef.value
|
||||
|
||||
if (!canvas) {
|
||||
console.error('PDF canvas ref not found')
|
||||
error.value = t('astrolabe', 'Canvas element not available')
|
||||
return
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
// Use scale for better resolution on high-DPI screens
|
||||
const viewport = page.getViewport({ scale: props.scale })
|
||||
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
|
||||
// Render page to canvas
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}
|
||||
|
||||
await page.render(renderContext).promise
|
||||
|
||||
emit('page-rendered', { pageNumber: pageNum })
|
||||
} catch (err) {
|
||||
console.error('PDF render error:', err)
|
||||
error.value = t('astrolabe', 'Error rendering PDF page')
|
||||
emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.pageNumber, (newPage) => {
|
||||
if (pdfDoc.value && newPage > 0 && newPage <= totalPages.value) {
|
||||
renderPage(newPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.filePath, () => {
|
||||
// Reload PDF if file path changes
|
||||
loadPDF()
|
||||
})
|
||||
|
||||
watch(loading, async (newLoading) => {
|
||||
// When loading completes, wait for canvas to be available and render
|
||||
if (!newLoading && pdfDoc.value && !error.value) {
|
||||
// Wait for Vue to update DOM
|
||||
await nextTick()
|
||||
// Canvas should now be rendered (v-else condition)
|
||||
if (canvasRef.value) {
|
||||
await renderPage(props.pageNumber)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Lifecycle hooks
|
||||
// Initial load
|
||||
onMounted(() => {
|
||||
loadPDF()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pdfDoc.value) {
|
||||
pdfDoc.value.destroy()
|
||||
}
|
||||
loadPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -206,19 +160,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-canvas-container {
|
||||
.pdf-image-container {
|
||||
position: relative;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: var(--color-main-background);
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.pdf-page-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+3
-2
@@ -2,10 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCP\Util;
|
||||
|
||||
Util::addScript(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main');
|
||||
Util::addStyle(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main');
|
||||
Util::addScript(Application::APP_ID, Application::APP_ID . '-main');
|
||||
Util::addStyle(Application::APP_ID, Application::APP_ID . '-main');
|
||||
|
||||
?>
|
||||
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Tests\Unit\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\BackgroundJob\RefreshUserTokens;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Lock\LockedException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for RefreshUserTokens background job.
|
||||
*
|
||||
* Tests proactive OAuth token refresh functionality.
|
||||
*/
|
||||
final class RefreshUserTokensTest extends TestCase {
|
||||
private ITimeFactory&MockObject $timeFactory;
|
||||
private McpTokenStorage&MockObject $tokenStorage;
|
||||
private IdpTokenRefresher&MockObject $tokenRefresher;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private RefreshUserTokens $job;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->timeFactory = $this->createMock(ITimeFactory::class);
|
||||
$this->tokenStorage = $this->createMock(McpTokenStorage::class);
|
||||
$this->tokenRefresher = $this->createMock(IdpTokenRefresher::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->job = new RefreshUserTokens(
|
||||
$this->timeFactory,
|
||||
$this->tokenStorage,
|
||||
$this->tokenRefresher,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up default withTokenLock behavior that executes the callback.
|
||||
* Call this in tests that need the lock to succeed.
|
||||
*/
|
||||
private function setupDefaultLockBehavior(): void {
|
||||
$this->tokenStorage->method('withTokenLock')
|
||||
->willReturnCallback(fn ($userId, $callback) => $callback());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Constructor Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testConstructorSetsInterval(): void {
|
||||
// Use reflection to access the protected interval property
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$property = $reflection->getProperty('interval');
|
||||
$property->setAccessible(true);
|
||||
|
||||
$this->assertEquals(900, $property->getValue($this->job));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// run() Method Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRunWithNoUsers(): void {
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn([]);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
$this->assertStringContainsString('Starting', $message);
|
||||
} else {
|
||||
$this->assertStringContainsString('total=0', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message);
|
||||
}
|
||||
});
|
||||
|
||||
// Call run() via reflection since it's protected
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunWithMultipleUsersAndMixedResults(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn(['alice', 'bob', 'charlie']);
|
||||
|
||||
// Alice: token with plenty of time (skipped)
|
||||
// Bob: token near expiry with refresh token (refreshed)
|
||||
// Charlie: token near expiry without refresh token (failed)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return match ($userId) {
|
||||
'alice' => [
|
||||
'access_token' => 'alice-token',
|
||||
'refresh_token' => 'alice-refresh',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime)
|
||||
'issued_at' => $now,
|
||||
],
|
||||
'bob' => [
|
||||
'access_token' => 'bob-token',
|
||||
'refresh_token' => 'bob-refresh',
|
||||
'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime)
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
'charlie' => [
|
||||
'access_token' => 'charlie-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
// Bob's refresh should succeed
|
||||
$this->tokenRefresher->method('refreshAccessToken')
|
||||
->with('bob-refresh')
|
||||
->willReturn([
|
||||
'access_token' => 'bob-new-token',
|
||||
'refresh_token' => 'bob-new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'bob',
|
||||
'bob-new-token',
|
||||
'bob-new-refresh',
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 2) {
|
||||
$this->assertStringContainsString('total=3', $message);
|
||||
$this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunProcessesUsersInBatches(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
// Simulate 150 users processed in 2 batches (100 + 50)
|
||||
$batch1 = array_map(fn ($i) => "user{$i}", range(1, 100));
|
||||
$batch2 = array_map(fn ($i) => "user{$i}", range(101, 150));
|
||||
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturnCallback(function (int $limit, int $offset) use (&$callCount, $batch1, $batch2) {
|
||||
$callCount++;
|
||||
// First call: offset 0, return 100 users (full batch)
|
||||
if ($offset === 0) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch1;
|
||||
}
|
||||
// Second call: offset 100, return 50 users (partial batch = last)
|
||||
if ($offset === 100) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch2;
|
||||
}
|
||||
// Should not be called again
|
||||
$this->fail("Unexpected getAllUsersWithTokens call with offset $offset");
|
||||
});
|
||||
|
||||
// All tokens have plenty of time (all skipped)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return [
|
||||
'access_token' => "{$userId}-token",
|
||||
'refresh_token' => "{$userId}-refresh",
|
||||
'expires_at' => $now + 3600,
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $infoCallCount = 0;
|
||||
$infoCallCount++;
|
||||
if ($infoCallCount === 2) {
|
||||
$this->assertStringContainsString('total=150', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=150', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
|
||||
// Verify getAllUsersWithTokens was called exactly twice (2 batches)
|
||||
$this->assertEquals(2, $callCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// refreshUserTokenIfNeeded() Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'valid-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining
|
||||
'issued_at' => $now,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshTriggeredWhenTokenNearExpiry(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s)
|
||||
'issued_at' => $now - 3300, // Issued 55 min ago
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenNoRefreshToken(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('no refresh token'));
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenRefresherReturnsNull(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'invalid-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('invalid-refresh')
|
||||
->willReturn(null);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('Refresh returned null'));
|
||||
|
||||
// Should NOT delete token - let on-demand refresh handle cleanup
|
||||
$this->tokenStorage->expects($this->never())
|
||||
->method('deleteUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesIssuedAtForLifetimeCalculation(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min)
|
||||
// 10/60 = 16.7% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 600, // 10 min remaining
|
||||
'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token without issued_at, uses default 3600s lifetime
|
||||
// 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 300, // 5 min remaining
|
||||
// No issued_at
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshStoresNewTokenWithIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'old-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Verify storeUserToken is called with issued_at parameter
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'new-refresh',
|
||||
$this->greaterThan($now), // expires_at = now + 3600
|
||||
$this->greaterThanOrEqual($now) // issued_at = now
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'original-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// IdP returns new access token but no new refresh token (no rotation)
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
// No refresh_token in response
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Should use the original refresh token
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'original-refresh', // Original refresh token preserved
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshHandlesException(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willThrowException(new \Exception('Network error'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Failed to refresh'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenNoToken(): void {
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn(null);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Locking Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenLockCannotBeAcquired(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100, // ~100s remaining (< 50% of default)
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// Lock acquisition fails (on-demand refresh is holding it)
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||
|
||||
// Token refresher should NOT be called when lock fails
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('Lock held for user testuser'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesLockForTokenRefresh(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->with('testuser', $this->isInstanceOf(\Closure::class))
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||
$now = time();
|
||||
|
||||
// First call (before lock): token is expiring
|
||||
// Calls inside lock callback: token is now fresh
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturnCallback(function () use (&$callCount, $now) {
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
// First check: token is expiring
|
||||
return [
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
];
|
||||
}
|
||||
// Inside lock: token was already refreshed
|
||||
return [
|
||||
'access_token' => 'already-refreshed-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_at' => $now + 3600, // Fresh token
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
// Token refresher should NOT be called since token is already fresh
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('already refreshed'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Invoke the protected run() method.
|
||||
*/
|
||||
private function invokeRun(): void {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('run');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->job, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the private refreshUserTokenIfNeeded() method.
|
||||
*/
|
||||
private function invokeRefreshUserTokenIfNeeded(string $userId): string {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('refreshUserTokenIfNeeded');
|
||||
$method->setAccessible(true);
|
||||
return $method->invoke($this->job, $userId);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Tests\Unit\Service;
|
||||
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ICrypto;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -19,7 +25,9 @@ use Psr\Log\LoggerInterface;
|
||||
final class McpTokenStorageTest extends TestCase {
|
||||
private IConfig&MockObject $config;
|
||||
private ICrypto&MockObject $crypto;
|
||||
private IDBConnection&MockObject $db;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private ILockingProvider&MockObject $lockingProvider;
|
||||
private McpTokenStorage $storage;
|
||||
|
||||
protected function setUp(): void {
|
||||
@@ -27,12 +35,16 @@ final class McpTokenStorageTest extends TestCase {
|
||||
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->crypto = $this->createMock(ICrypto::class);
|
||||
$this->db = $this->createMock(IDBConnection::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->lockingProvider = $this->createMock(ILockingProvider::class);
|
||||
|
||||
$this->storage = new McpTokenStorage(
|
||||
$this->config,
|
||||
$this->crypto,
|
||||
$this->logger
|
||||
$this->db,
|
||||
$this->logger,
|
||||
$this->lockingProvider
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,15 +58,15 @@ final class McpTokenStorageTest extends TestCase {
|
||||
$refreshToken = 'refresh-token-456';
|
||||
$expiresAt = time() + 3600;
|
||||
|
||||
$expectedTokenData = [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
|
||||
$this->crypto->expects($this->once())
|
||||
->method('encrypt')
|
||||
->with(json_encode($expectedTokenData))
|
||||
->with($this->callback(function (string $json) use ($accessToken, $refreshToken, $expiresAt) {
|
||||
$data = json_decode($json, true);
|
||||
return $data['access_token'] === $accessToken
|
||||
&& $data['refresh_token'] === $refreshToken
|
||||
&& $data['expires_at'] === $expiresAt
|
||||
&& isset($data['issued_at']); // issued_at should be set (defaults to time())
|
||||
}))
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->config->expects($this->once())
|
||||
@@ -284,6 +296,155 @@ final class McpTokenStorageTest extends TestCase {
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Refresh Locking Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAccessTokenAcquiresLockWhenRefreshing(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$newTokenData = [
|
||||
'access_token' => 'new-access-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
$this->crypto->method('encrypt')
|
||||
->willReturn('new-encrypted-data');
|
||||
|
||||
// Verify lock is acquired and released
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock')
|
||||
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('releaseLock')
|
||||
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||
|
||||
$refreshCallback = fn (string $refreshToken) => $newTokenData;
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertEquals('new-access-token', $result);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenReturnsStaleTokenOnLockedException(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
// Lock acquisition fails
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock')
|
||||
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||
|
||||
// Refresh callback should NOT be called when lock fails
|
||||
$refreshCallbackCalled = false;
|
||||
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||
$refreshCallbackCalled = true;
|
||||
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||
};
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
// Should return stale token instead of failing
|
||||
$this->assertEquals('expired-access-token', $result);
|
||||
$this->assertFalse($refreshCallbackCalled);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenSkipsRefreshWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
// After lock is acquired, token appears fresh (another process refreshed it)
|
||||
$freshTokenData = [
|
||||
'access_token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||
];
|
||||
|
||||
$callCount = 0;
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
// First call returns expired, subsequent calls return fresh
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturnCallback(function () use (&$callCount, $expiredTokenData, $freshTokenData) {
|
||||
$callCount++;
|
||||
return $callCount === 1
|
||||
? json_encode($expiredTokenData)
|
||||
: json_encode($freshTokenData);
|
||||
});
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock');
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('releaseLock');
|
||||
|
||||
// Refresh callback should NOT be called since token is already fresh
|
||||
$refreshCallbackCalled = false;
|
||||
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||
$refreshCallbackCalled = true;
|
||||
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||
};
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertEquals('fresh-access-token', $result);
|
||||
$this->assertFalse($refreshCallbackCalled);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenNoLockRequiredWhenNotExpired(): void {
|
||||
$userId = 'testuser';
|
||||
$validTokenData = [
|
||||
'access_token' => 'valid-access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($validTokenData));
|
||||
|
||||
// Lock should NOT be acquired for valid tokens
|
||||
$this->lockingProvider->expects($this->never())
|
||||
->method('acquireLock');
|
||||
|
||||
$this->lockingProvider->expects($this->never())
|
||||
->method('releaseLock');
|
||||
|
||||
$result = $this->storage->getAccessToken($userId);
|
||||
|
||||
$this->assertEquals('valid-access-token', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Password Storage Tests (Multi-User Basic Auth)
|
||||
// =========================================================================
|
||||
@@ -524,4 +685,145 @@ final class McpTokenStorageTest extends TestCase {
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getAllUsersWithTokens Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAllUsersWithTokensReturnsUserIds(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set with multiple users
|
||||
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
['userid' => 'admin'],
|
||||
['userid' => 'alice'],
|
||||
['userid' => 'bob'],
|
||||
false // End of results
|
||||
);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens();
|
||||
|
||||
$this->assertEquals(['admin', 'alice', 'bob'], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensReturnsEmptyArrayWhenNoTokens(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock empty result set
|
||||
$result->method('fetch')->willReturn(false);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens();
|
||||
|
||||
$this->assertEquals([], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensWithLimitAndOffset(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Verify setMaxResults and setFirstResult are called with correct values
|
||||
$qb->expects($this->once())
|
||||
->method('setMaxResults')
|
||||
->with(50)
|
||||
->willReturnSelf();
|
||||
$qb->expects($this->once())
|
||||
->method('setFirstResult')
|
||||
->with(100)
|
||||
->willReturnSelf();
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set
|
||||
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
['userid' => 'user1'],
|
||||
['userid' => 'user2'],
|
||||
false
|
||||
);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens(50, 100);
|
||||
|
||||
$this->assertEquals(['user1', 'user2'], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensWithZeroLimitDoesNotSetMaxResults(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// setMaxResults should NOT be called when limit is 0
|
||||
$qb->expects($this->never())
|
||||
->method('setMaxResults');
|
||||
|
||||
// setFirstResult should NOT be called when offset is 0
|
||||
$qb->expects($this->never())
|
||||
->method('setFirstResult');
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set
|
||||
$result->method('fetch')->willReturn(false);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$this->storage->getAllUsersWithTokens(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-14
@@ -566,16 +566,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "10.5.60",
|
||||
"version": "10.5.63",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c"
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -596,7 +596,7 @@
|
||||
"phpunit/php-timer": "^6.0.0",
|
||||
"sebastian/cli-parser": "^2.0.1",
|
||||
"sebastian/code-unit": "^2.0.0",
|
||||
"sebastian/comparator": "^5.0.4",
|
||||
"sebastian/comparator": "^5.0.5",
|
||||
"sebastian/diff": "^5.1.1",
|
||||
"sebastian/environment": "^6.1.0",
|
||||
"sebastian/exporter": "^5.1.4",
|
||||
@@ -647,7 +647,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -671,7 +671,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-06T07:50:42+00:00"
|
||||
"time": "2026-01-27T05:48:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -843,16 +843,16 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
"version": "5.0.4",
|
||||
"version": "5.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e"
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -908,7 +908,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4"
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -928,7 +928,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-07T05:25:07+00:00"
|
||||
"time": "2026-01-24T09:25:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/complexity",
|
||||
@@ -1687,5 +1687,5 @@
|
||||
"platform-overrides": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user