Compare commits

...

48 Commits

Author SHA1 Message Date
Chris Coutinho c7882adb24 docs: add authentication flows reference by deployment mode
Create unified documentation covering authentication flows across all five
deployment modes. Documents three communication patterns (MCP Client → MCP
Server → Nextcloud, background sync, Astrolabe → MCP Server) with ASCII
sequence diagrams and implementation references.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:38:29 +01:00
Chris Coutinho 9491d698e8 fix(astrolabe): add pagination and psalm fixes for token refresh
- Add pagination to getAllUsersWithTokens() with limit/offset params
- Update RefreshUserTokens to process users in batches of 100
- Add lock TTL documentation to withTokenLock() docstring
- Fix psalm type errors in getAccessToken() method
- Add unit tests for pagination and batched processing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:13:22 +01:00
Chris Coutinho 5b71ac3251 fix(astrolabe): add locking to prevent token refresh race condition
Adds distributed locking using Nextcloud's ILockingProvider to prevent
race conditions between background job and on-demand token refresh.

Uses double-check locking pattern:
1. Quick check without lock - return immediately if token is valid
2. Acquire exclusive lock if token needs refresh
3. Re-check after lock - another process may have refreshed
4. Refresh only if still needed
5. Graceful degradation on LockedException

Changes:
- McpTokenStorage: add ILockingProvider, withTokenLock() method
- McpTokenStorage: update getAccessToken() with locking pattern
- RefreshUserTokens: wrap refresh in withTokenLock(), catch LockedException
- Add comprehensive unit tests for locking behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:23:42 +01:00
Chris Coutinho 815a09be34 test(astrolabe): add unit tests for background token refresh
- Fix McpTokenStorageTest: add IDBConnection mock for new constructor parameter
- Add doctrine/dbal dev dependency for IQueryBuilder mock support
- Add tests for getAllUsersWithTokens() database query method
- Create RefreshUserTokensTest with comprehensive coverage:
  - Job interval configuration (15 min)
  - Token refresh threshold logic (50% lifetime)
  - issued_at tracking for accurate lifetime calculation
  - Fallback to default lifetime when issued_at missing
  - Token rotation handling
  - Error handling and logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:23:06 +01:00
Chris Coutinho c46f9eb212 fix(astrolabe): add issued_at to on-demand token refresh
Fixes missing issued_at parameter when storing tokens refreshed via
getAccessToken() callback, ensuring accurate token lifetime calculation
for the background refresh job.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:25:43 +01:00
Chris Coutinho 28219e00e7 feat(astrolabe): add background token refresh job
Prevents users from having to re-authorize Astrolabe after periods of
inactivity by proactively refreshing OAuth tokens before they expire.

Changes:
- Add RefreshUserTokens background job that runs every 15 minutes
- Add on-demand token refresh in SemanticSearchProvider (Unified Search)
- Store issued_at timestamp for accurate token lifetime calculation
- Add getAllUsersWithTokens() to query users needing refresh

The job dynamically calculates refresh threshold based on actual token
lifetime (50% remaining), working with any IdP (Nextcloud OIDC, Keycloak,
etc.) rather than relying on IdP-specific configuration.

Closes #510

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 10:34:39 +01:00
github-actions[bot] daaf460b0c bump: version 0.8.3 → 0.9.0 2026-01-26 21:02:22 +00:00
github-actions[bot] 04f05f725c bump: version 0.57.14 → 0.57.15 2026-01-26 21:02:21 +00:00
github-actions[bot] b499aa2abe bump: version 0.61.5 → 0.62.0 2026-01-26 21:02:21 +00:00
Chris Coutinho 72df7dd1eb Merge pull request #501 from cbcoutinho/feat/pymupdf-pdf-rendering
feat(astrolabe): replace PDF.js with server-side PyMuPDF rendering
2026-01-26 22:02:01 +01:00
Chris Coutinho 2e7774654b refactor(api): split management.py into domain-focused modules
Split the monolithic management.py (1988 lines) into 4 focused modules:
- management.py: Server status, user sessions, shared helpers (~520 lines)
- passwords.py: App password provisioning for BasicAuth mode (~300 lines)
- webhooks.py: Webhook registration management (~290 lines)
- visualization.py: Search and PDF preview endpoints (~810 lines)

Backward compatibility maintained via __init__.py re-exports.
Updated test imports to use new module paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:28:18 +01:00
Chris Coutinho 61ce873411 chore: Address reviewer comments and add error handling to PDF chunk viz preview endpoints 2026-01-26 21:16:31 +01:00
github-actions[bot] 0af9657fea bump: version 0.57.13 → 0.57.14 2026-01-26 19:29:37 +00:00
Chris Coutinho 8507e480d6 Merge remote-tracking branch 'origin/master' into feat/pymupdf-pdf-rendering 2026-01-26 20:29:34 +01:00
Chris Coutinho 905d18baf7 ci(claude): allow renovate bot to trigger code reviews
Add allowed_bots configuration for renovate-bot-cbcoutinho to enable
Claude Code review on dependency update PRs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:29:17 +01:00
Chris Coutinho b5e5d86790 ci(claude): allow renovate bot to trigger code reviews
Add allowed_bots configuration for renovate-bot-cbcoutinho to enable
Claude Code review on dependency update PRs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:27:17 +01:00
Chris Coutinho c35e94b0bc test(api): add unit tests for PDF preview management endpoint
Add comprehensive unit tests for the /api/v1/pdf-preview endpoint:
- Parameter validation (file_path, page, scale)
- OAuth token authentication
- PDF rendering with PyMuPDF
- Error handling (file not found, invalid page, corrupted PDF)
- Edge cases (URL-encoded paths, boundary values, missing config)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:24:33 +01:00
Chris Coutinho c09ebe99cc fix(astrolabe): resolve Psalm type errors in PDF preview code
Fix Psalm static analysis errors:
- Add return type annotations to refresh callback closures
- Use strict null comparisons instead of truthy/falsy checks
- Cast response body to string for json_decode
- Add type annotation for decoded JSON data
- Update psalm-baseline.xml to remove fixed issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:19:23 +01:00
Chris Coutinho d5544a7731 refactor(astrolabe): replace client-side PDF.js with server-side PyMuPDF rendering
Replace the client-side PDF.js viewer with server-side rendering using PyMuPDF.
This avoids CSP worker restrictions and ES private field access issues that
affected Chromium browsers.

Changes:
- Add /api/v1/pdf-preview endpoint to MCP server (management.py)
- Add pdf-preview route and controller action in Astrolabe PHP backend
- Refactor PDFViewer.vue to display server-rendered PNG images
- Remove pdfjs-dist dependency and client-side PDF loading code
- Use @nextcloud/axios for CSRF token handling in PDFViewer

The server downloads the PDF via WebDAV, renders the requested page with
PyMuPDF at the specified scale, and returns a base64-encoded PNG image.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:04:57 +01:00
Chris Coutinho bc62f2a066 fix(astrolabe): fix Psalm baseline and ESLint import order
- Update psalm-baseline.xml to match renamed OauthController.php (lowercase 'a')
- Move AlertCircle import to top of PDFViewer.vue to satisfy ESLint import/first rule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:41:48 +01:00
Chris Coutinho 38adb96be4 fix(astrolabe): load pdfjs-dist externally to fix PDF viewer
When viewing PDF chunks in semantic search, the PDF viewer failed with
"can't access private field" errors. This was caused by:

1. CSP blocks web workers (worker-src 'none'), forcing fake worker mode
2. Vite transforms ES private fields in the bundle, but the worker file
   is untransformed, causing incompatible private field implementations
3. Vue's ref() wraps PDFDocumentProxy in a Proxy, which can't access
   ES private fields

Fixed by:
- Loading pdfjs-dist externally via script tag (avoids Vite transform)
- Creating pdfjs-loader.mjs that imports pdf.mjs and sets window.pdfjsLib
- Using Util::addScript() for CSP-compliant script loading with nonces
- Using shallowRef() instead of ref() for pdfDoc to avoid Proxy wrapper
- Setting workerSrc at runtime using OC.linkTo() for correct app path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:08:44 +01:00
Chris Coutinho c76dd21eeb fix(astrolabe): improve error messages for authorization issues
Replace generic "Network error" with specific error messages:
- Show backend error message when available from HTTP response
- Display "Authorization required. Please complete Step 1 in
  Settings → Astrolabe." for 401 Unauthorized errors
- Show "Search service unavailable" for 503 errors
- Keep generic network error only for actual connection failures

This helps users understand when they need to complete OAuth
authorization vs when there's an actual network problem.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:21:57 +01:00
Chris Coutinho c5bf4cda8a fix(astrolabe): rename OAuthController and fix app password check
- Rename OAuthController.php to OauthController.php for consistency
- Fix Personal.php to check specifically for app password presence
  using getBackgroundSyncPassword() instead of hasBackgroundSyncAccess()
  for hybrid auth mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 13:04:23 +01:00
Chris Coutinho 0b6a6b0842 fix(tests): improve Astrolabe integration test reliability
- Replace Close button click with Escape key in app password dialog
  (h2 element was intercepting pointer events)
- Make test_users_setup fixture idempotent by checking user existence
  before creation and only tracking created users for cleanup
- Fix search results detection by removing wait for .app-content-wrapper
  CSS class that doesn't exist in Astrolabe's Vue app
- Add progress logging during results polling
- Increase polling timeout to 30 seconds for search results

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 13:03:35 +01:00
github-actions[bot] 9c4c4d4563 bump: version 0.57.12 → 0.57.13 2026-01-24 11:45:02 +00:00
Chris Coutinho 2d74b1a1fb Merge pull request #495 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.34
2026-01-24 12:44:48 +01:00
renovate-bot-cbcoutinho[bot] 26ba237142 chore(deps): update anthropics/claude-code-action action to v1.0.34 2026-01-24 11:07:19 +00:00
Chris Coutinho 7b75304c9f feat(scripts): add database query helpers for development
Add dbquery.py for MariaDB and sqlitequery.py for SQLite databases
in MCP service containers. Both scripts wrap docker compose exec to
simplify database inspection during development.

- dbquery.py: Query Nextcloud MariaDB with vertical/JSON output
- sqlitequery.py: Query MCP service SQLite DBs with service aliases
  (mcp, oauth, keycloak, basic) and column/JSON output modes
- Document both scripts in CLAUDE.md Database Inspection section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:26:30 +01:00
Chris Coutinho 9004e14022 Merge remote-tracking branch 'origin/master' into renovate/plotly.js-dist-min-3.x 2026-01-21 18:22:05 +01:00
Chris Coutinho e7a3dd698a fix(astrolabe): update Plotly title attributes for v3 compatibility
Plotly.js v3 removed string format for title attributes (plotly/plotly.js#7212).
All titles must now use object format: { text: "..." }

Changes:
- Main layout title: string → { text: "..." }
- Scene axis titles (xaxis, yaxis, zaxis): string → { text: "..." }
- Colorbar title: string → { text: "..." }

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:04:49 +01:00
github-actions[bot] c12007c342 bump: version 0.57.11 → 0.57.12 2026-01-20 14:26:42 +00:00
Chris Coutinho f37cf8a159 Merge pull request #492 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.5
chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 11a3a4f
2026-01-20 15:26:23 +01:00
github-actions[bot] 07f2952599 bump: version 0.57.10 → 0.57.11 2026-01-20 13:27:05 +00:00
Chris Coutinho 6cf916876a Merge pull request #493 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.38.0
2026-01-20 14:26:48 +01:00
renovate-bot-cbcoutinho[bot] 27b11eabf9 chore(deps): update helm release ollama to v1.38.0 2026-01-20 11:12:04 +00:00
renovate-bot-cbcoutinho[bot] da31dec33e chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 11a3a4f 2026-01-20 11:11:24 +00:00
github-actions[bot] a61bcccdac bump: version 0.57.9 → 0.57.10 2026-01-19 12:42:51 +00:00
Chris Coutinho 774de68966 Merge pull request #488 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 5e2dbd4
2026-01-19 13:42:35 +01:00
github-actions[bot] 44b77875f7 bump: version 0.57.8 → 0.57.9 2026-01-19 12:41:25 +00:00
Chris Coutinho 5469cf05f0 Merge pull request #490 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.5
2026-01-19 13:41:10 +01:00
renovate-bot-cbcoutinho[bot] 6832ae1198 fix(deps): update dependency plotly.js-dist-min to v3 2026-01-18 11:14:27 +00:00
github-actions[bot] 619faaf1df bump: version 0.57.7 → 0.57.8 2026-01-18 10:57:38 +00:00
Chris Coutinho 34387ff202 Merge pull request #489 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.30
2026-01-18 11:57:23 +01:00
github-actions[bot] 76d3174264 bump: version 0.8.2 → 0.8.3 2026-01-17 20:38:00 +00:00
github-actions[bot] 723337754f bump: version 0.57.6 → 0.57.7 2026-01-17 20:38:00 +00:00
renovate-bot-cbcoutinho[bot] c9bf3d0b52 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.5 2026-01-17 11:11:53 +00:00
renovate-bot-cbcoutinho[bot] 9f64609722 chore(deps): update anthropics/claude-code-action action to v1.0.30 2026-01-17 11:11:35 +00:00
renovate-bot-cbcoutinho[bot] 89becbb92b chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 5e2dbd4 2026-01-17 11:11:30 +00:00
48 changed files with 6058 additions and 1612 deletions
+2 -1
View File
@@ -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@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
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 }}
+1 -1
View File
@@ -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@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+22
View File
@@ -5,6 +5,28 @@ 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.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
+53
View File
@@ -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:**
+1 -1
View File
@@ -1,4 +1,4 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
+1 -1
View File
@@ -12,7 +12,7 @@
# - 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:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
WORKDIR /app
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.57.6"
version = "0.57.15"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+50
View File
@@ -14,6 +14,56 @@ 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.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)
+3 -3
View File
@@ -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.38.0
digest: sha256:60b09d52759c84f8add5782c867f5a373aa6eb2477dc9380bef0134183c4b1ae
generated: "2026-01-20T11:11:57.230612063Z"
+3 -3
View File
@@ -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.15
appVersion: "0.62.0"
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.38.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+1 -1
View File
@@ -23,7 +23,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.4@sha256:9ca3f78fcca340ea32ab7bf1a01b2a2fd3eae64ffc0e791fd71eb9d72c3d2efe
image: docker.io/library/nextcloud:32.0.5@sha256:11a3a4f63bad8813c7455b4a3c473ccd1c41e2c48f55decb51718f15691e7568
restart: always
ports:
- 127.0.0.1:8080:80
+461
View File
@@ -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
+70
View File
@@ -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
+429
View File
@@ -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,
)
+813
View File
@@ -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,
)
+308
View File
@@ -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,
)
+5 -2
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.61.5"
version = "0.62.0"
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"}
+145
View File
@@ -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())
+177
View File
@@ -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())
+31 -22
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.8.2"
version = "0.9.0"
tag_format = "astrolabe-v$version"
version_scheme = "semver"
update_changelog_on_bump = true
+36
View File
@@ -25,6 +25,42 @@ 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.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
View File
@@ -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.9.0</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
View File
@@ -72,6 +72,11 @@ return [
'url' => '/api/chunk-context',
'verb' => 'GET',
],
[
'name' => 'api#pdfPreview',
'url' => '/api/pdf-preview',
'verb' => 'GET',
],
// Admin settings routes
[
+1
View File
@@ -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"
+303 -1
View File
@@ -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",
@@ -775,6 +1028,55 @@
],
"time": "2025-12-06T07:50:42+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",
"version": "1.0.0",
@@ -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
View File
@@ -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);
}
}
@@ -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
View File
@@ -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(), []);
}
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+6 -4
View File
@@ -1,12 +1,12 @@
{
"name": "astrolabe",
"version": "0.8.2",
"version": "0.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astrolabe",
"version": "0.8.2",
"version": "0.8.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nextcloud/axios": "^2.5.1",
@@ -17,7 +17,7 @@
"@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"
},
@@ -7905,7 +7905,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": {
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "astrolabe",
"version": "0.8.2",
"version": "0.9.0",
"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
View File
@@ -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>
+27 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.61.5"
version = "0.62.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },