Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7882adb24 | |||
| 9491d698e8 | |||
| 5b71ac3251 | |||
| 815a09be34 | |||
| c46f9eb212 | |||
| 28219e00e7 | |||
| daaf460b0c | |||
| 04f05f725c | |||
| b499aa2abe | |||
| 72df7dd1eb | |||
| 2e7774654b | |||
| 61ce873411 | |||
| 0af9657fea | |||
| 8507e480d6 | |||
| 905d18baf7 | |||
| b5e5d86790 | |||
| c35e94b0bc | |||
| c09ebe99cc | |||
| d5544a7731 | |||
| bc62f2a066 | |||
| 38adb96be4 | |||
| c76dd21eeb | |||
| c5bf4cda8a | |||
| 0b6a6b0842 | |||
| 9c4c4d4563 | |||
| 2d74b1a1fb | |||
| 26ba237142 | |||
| 7b75304c9f | |||
| 9004e14022 | |||
| e7a3dd698a | |||
| c12007c342 | |||
| f37cf8a159 | |||
| 07f2952599 | |||
| 6cf916876a | |||
| 27b11eabf9 | |||
| da31dec33e | |||
| 6832ae1198 |
@@ -33,9 +33,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@a017b830c03e23789b11fb69ed571ea61c12e45c # v1.0.30
|
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
allowed_bots: "renovate-bot-cbcoutinho"
|
||||||
prompt: |
|
prompt: |
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@a017b830c03e23789b11fb69ed571ea61c12e45c # v1.0.30
|
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -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/),
|
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/).
|
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)
|
## v0.61.5 (2026-01-17)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
|
|||||||
|
|
||||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
**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
|
```bash
|
||||||
# Connect to database
|
# Connect to database
|
||||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
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_registration_tokens` - RFC 7592 registration tokens
|
||||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
- `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
|
## Architecture Quick Reference
|
||||||
|
|
||||||
**For detailed architecture, see:**
|
**For detailed architecture, see:**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.10"
|
version = "0.57.15"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
@@ -14,6 +14,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- 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.10 (2026-01-19)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.9 (2026-01-19)
|
## nextcloud-mcp-server-0.57.9 (2026-01-19)
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ dependencies:
|
|||||||
version: 1.16.3
|
version: 1.16.3
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.37.0
|
version: 1.38.0
|
||||||
digest: sha256:0ce3bb4b5e95a3b8fde3f5f374d7b62aeafcb0dcf8a60b9d95978530b6c05b68
|
digest: sha256:60b09d52759c84f8add5782c867f5a373aa6eb2477dc9380bef0134183c4b1ae
|
||||||
generated: "2026-01-08T11:11:12.857375888Z"
|
generated: "2026-01-20T11:11:57.230612063Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.57.10
|
version: 0.57.15
|
||||||
appVersion: "0.61.5"
|
appVersion: "0.62.0"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -31,6 +31,6 @@ dependencies:
|
|||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.37.0"
|
version: "1.38.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.5@sha256:78ae30695b550aee857d8714d395fcb0e8005fc2bfde8cd88ec7dee102660e69
|
image: docker.io/library/nextcloud:32.0.5@sha256:11a3a4f63bad8813c7455b4a3c473ccd1c41e2c48f55decb51718f15691e7568
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8080:80
|
- 127.0.0.1:8080:80
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,4 +3,74 @@
|
|||||||
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
||||||
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
||||||
authentication via the UnifiedTokenVerifier.
|
authentication via the UnifiedTokenVerifier.
|
||||||
|
|
||||||
|
This package is organized into modules by domain:
|
||||||
|
- management.py: Server status, user sessions, shared helpers
|
||||||
|
- passwords.py: App password provisioning for multi-user BasicAuth
|
||||||
|
- webhooks.py: Webhook registration management
|
||||||
|
- visualization.py: Search and PDF visualization endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Re-export all public functions for backward compatibility
|
||||||
|
from nextcloud_mcp_server.api.management import (
|
||||||
|
__version__,
|
||||||
|
_parse_float_param,
|
||||||
|
_parse_int_param,
|
||||||
|
_sanitize_error_for_client,
|
||||||
|
_validate_query_string,
|
||||||
|
extract_bearer_token,
|
||||||
|
get_server_status,
|
||||||
|
get_user_session,
|
||||||
|
get_vector_sync_status,
|
||||||
|
revoke_user_access,
|
||||||
|
validate_token_and_get_user,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.api.passwords import (
|
||||||
|
delete_app_password,
|
||||||
|
get_app_password_status,
|
||||||
|
provision_app_password,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.api.visualization import (
|
||||||
|
get_chunk_context,
|
||||||
|
get_pdf_preview,
|
||||||
|
unified_search,
|
||||||
|
vector_search,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.api.webhooks import (
|
||||||
|
create_webhook,
|
||||||
|
delete_webhook,
|
||||||
|
get_installed_apps,
|
||||||
|
list_webhooks,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Version
|
||||||
|
"__version__",
|
||||||
|
# Shared helpers (from management.py)
|
||||||
|
"extract_bearer_token",
|
||||||
|
"validate_token_and_get_user",
|
||||||
|
"_sanitize_error_for_client",
|
||||||
|
"_parse_int_param",
|
||||||
|
"_parse_float_param",
|
||||||
|
"_validate_query_string",
|
||||||
|
# Status endpoints (from management.py)
|
||||||
|
"get_server_status",
|
||||||
|
"get_vector_sync_status",
|
||||||
|
# Session endpoints (from management.py)
|
||||||
|
"get_user_session",
|
||||||
|
"revoke_user_access",
|
||||||
|
# Password endpoints (from passwords.py)
|
||||||
|
"provision_app_password",
|
||||||
|
"get_app_password_status",
|
||||||
|
"delete_app_password",
|
||||||
|
# Webhook endpoints (from webhooks.py)
|
||||||
|
"get_installed_apps",
|
||||||
|
"list_webhooks",
|
||||||
|
"create_webhook",
|
||||||
|
"delete_webhook",
|
||||||
|
# Visualization endpoints (from visualization.py)
|
||||||
|
"unified_search",
|
||||||
|
"vector_search",
|
||||||
|
"get_chunk_context",
|
||||||
|
"get_pdf_preview",
|
||||||
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,429 @@
|
|||||||
|
"""App password management API endpoints.
|
||||||
|
|
||||||
|
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
|
||||||
|
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||||
|
- Store app passwords for background sync operations
|
||||||
|
- Check app password status
|
||||||
|
- Delete stored app passwords
|
||||||
|
|
||||||
|
Authentication is via BasicAuth with the user's Nextcloud credentials.
|
||||||
|
Passwords are validated against Nextcloud before being stored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||||
|
APP_PASSWORD_PATTERN = re.compile(
|
||||||
|
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timeout for Nextcloud API validation requests (seconds)
|
||||||
|
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# Rate limiting configuration for app password provisioning
|
||||||
|
# Limits: 5 attempts per user per hour
|
||||||
|
RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
|
||||||
|
|
||||||
|
# In-memory rate limiter storage
|
||||||
|
# Structure: {user_id: [(timestamp, success), ...]}
|
||||||
|
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
|
||||||
|
"""Check if user is rate limited for app password operations.
|
||||||
|
|
||||||
|
Implements a sliding window rate limiter to prevent brute-force attacks
|
||||||
|
on the app password provisioning endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_allowed, seconds_until_retry)
|
||||||
|
- is_allowed: True if request should be allowed
|
||||||
|
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
|
||||||
|
|
||||||
|
# Clean up old attempts outside the window
|
||||||
|
_rate_limit_attempts[user_id] = [
|
||||||
|
(ts, success)
|
||||||
|
for ts, success in _rate_limit_attempts[user_id]
|
||||||
|
if ts > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
# Count recent attempts (both successful and failed)
|
||||||
|
recent_attempts = len(_rate_limit_attempts[user_id])
|
||||||
|
|
||||||
|
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||||
|
# Find when the oldest attempt in the window will expire
|
||||||
|
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
|
||||||
|
seconds_until_retry = int(
|
||||||
|
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
|
||||||
|
)
|
||||||
|
return False, max(1, seconds_until_retry)
|
||||||
|
|
||||||
|
return True, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
|
||||||
|
"""Record an app password provisioning attempt for rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
success: Whether the attempt was successful
|
||||||
|
"""
|
||||||
|
_rate_limit_attempts[user_id].append((time.time(), success))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_basic_auth(
|
||||||
|
request: Request, path_user_id: str
|
||||||
|
) -> tuple[str, str, JSONResponse | None]:
|
||||||
|
"""Extract and validate BasicAuth credentials from request.
|
||||||
|
|
||||||
|
Validates:
|
||||||
|
1. Authorization header is present and valid BasicAuth format
|
||||||
|
2. Username in credentials matches the path user_id
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request with Authorization header
|
||||||
|
path_user_id: User ID from the URL path to verify against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (username, password, error_response)
|
||||||
|
- If successful: (username, password, None)
|
||||||
|
- If failed: ("", "", JSONResponse with error)
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
|
||||||
|
if not auth_header or not auth_header.startswith("Basic "):
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Missing BasicAuth credentials"},
|
||||||
|
status_code=401,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode BasicAuth
|
||||||
|
encoded = auth_header.split(" ", 1)[1]
|
||||||
|
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid BasicAuth format"},
|
||||||
|
status_code=401,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify username matches path user_id
|
||||||
|
if username != path_user_id:
|
||||||
|
logger.warning(
|
||||||
|
f"Username mismatch in app password operation for path user {path_user_id}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Username does not match path user_id"},
|
||||||
|
status_code=403,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return username, password, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||||
|
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||||
|
|
||||||
|
Checks app.state.storage first, then falls back to creating from environment.
|
||||||
|
This helper avoids repeated storage initialization logic across endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request with app state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Initialized RefreshTokenStorage instance
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
storage = getattr(request.app.state, "storage", None)
|
||||||
|
|
||||||
|
if not storage:
|
||||||
|
# Multi-user BasicAuth mode may not have oauth_context
|
||||||
|
# Initialize storage from environment
|
||||||
|
storage = RefreshTokenStorage.from_env()
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
|
async def provision_app_password(request: Request) -> JSONResponse:
|
||||||
|
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
|
||||||
|
|
||||||
|
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
|
||||||
|
for multi-user BasicAuth mode background sync.
|
||||||
|
|
||||||
|
The request must include BasicAuth credentials where:
|
||||||
|
- username: Nextcloud user ID (must match path user_id)
|
||||||
|
- password: The app password being provisioned
|
||||||
|
|
||||||
|
The MCP server validates the app password against Nextcloud before storing it.
|
||||||
|
This proves the user owns the password and has access to Nextcloud.
|
||||||
|
|
||||||
|
Security model:
|
||||||
|
- User identity is verified via BasicAuth against Nextcloud
|
||||||
|
- App password is encrypted before storage
|
||||||
|
- Only the user who owns the password can provision it
|
||||||
|
- Rate limited to prevent brute-force attacks
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check rate limit before processing
|
||||||
|
is_allowed, retry_after = _check_rate_limit(path_user_id)
|
||||||
|
if not is_allowed:
|
||||||
|
logger.warning(
|
||||||
|
f"Rate limit exceeded for app password provisioning: {path_user_id}"
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||||
|
},
|
||||||
|
status_code=429,
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Validate app password format
|
||||||
|
if not APP_PASSWORD_PATTERN.match(app_password):
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid app password format"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Nextcloud host from settings
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
logger.error("NEXTCLOUD_HOST not configured")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Server not configured"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate app password against Nextcloud
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||||
|
# Use OCS API to verify credentials
|
||||||
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
|
response = await client.get(
|
||||||
|
test_url,
|
||||||
|
auth=(username, app_password),
|
||||||
|
params={"format": "json"},
|
||||||
|
headers={"OCS-APIRequest": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
f"App password validation failed for user: HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid app password"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the user ID from response matches
|
||||||
|
data = response.json()
|
||||||
|
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
|
||||||
|
if ocs_user_id != username:
|
||||||
|
logger.warning("User ID mismatch in OCS response")
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "User ID mismatch"},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to validate app password: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Failed to validate credentials"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the validated app password
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
await storage.store_app_password(username, app_password)
|
||||||
|
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=True)
|
||||||
|
logger.info(f"Provisioned app password for user: {username}")
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"App password stored for {username}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "provision_app_password")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_app_password_status(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
|
||||||
|
|
||||||
|
Returns status of background sync access for multi-user BasicAuth mode.
|
||||||
|
|
||||||
|
Requires BasicAuth with the user's app password for authentication.
|
||||||
|
"""
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
app_password = await storage.get_app_password(username)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": username,
|
||||||
|
"has_app_password": app_password is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_app_password(request: Request) -> JSONResponse:
|
||||||
|
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
|
||||||
|
|
||||||
|
Removes the user's app password from MCP server storage.
|
||||||
|
|
||||||
|
Requires BasicAuth with the user's credentials.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, password, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Validate credentials against Nextcloud
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||||
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
|
response = await client.get(
|
||||||
|
test_url,
|
||||||
|
auth=(username, password),
|
||||||
|
params={"format": "json"},
|
||||||
|
headers={"OCS-APIRequest": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid credentials"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to validate credentials: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Failed to validate credentials"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
deleted = await storage.delete_app_password(username)
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"Deleted app password for user: {username}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"App password deleted for {username}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "No app password found to delete",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "delete_app_password")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
"""Visualization API endpoints for search and PDF preview.
|
||||||
|
|
||||||
|
ADR-018: Provides REST API endpoints for the Nextcloud PHP app (Astrolabe) to:
|
||||||
|
- Execute unified search with semantic/BM25/hybrid algorithms
|
||||||
|
- Execute vector search with PCA visualization coordinates
|
||||||
|
- Fetch chunk context with surrounding text
|
||||||
|
- Render PDF pages server-side (avoiding CSP/worker issues)
|
||||||
|
|
||||||
|
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.management import (
|
||||||
|
_parse_float_param,
|
||||||
|
_parse_int_param,
|
||||||
|
_sanitize_error_for_client,
|
||||||
|
_validate_query_string,
|
||||||
|
extract_bearer_token,
|
||||||
|
validate_token_and_get_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def unified_search(request: Request) -> JSONResponse:
|
||||||
|
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
||||||
|
|
||||||
|
Optimized search endpoint for the Nextcloud Unified Search provider
|
||||||
|
and other PHP app integrations. Returns results with metadata needed
|
||||||
|
for navigation to source documents.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"query": "search query",
|
||||||
|
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||||
|
"limit": 20, // max: 100
|
||||||
|
"offset": 0, // pagination offset
|
||||||
|
"include_pca": false, // optional PCA coordinates
|
||||||
|
"include_chunks": true // include text snippets
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"results": [{
|
||||||
|
"id": "doc123",
|
||||||
|
"doc_type": "note",
|
||||||
|
"title": "Document Title",
|
||||||
|
"excerpt": "Matching text snippet...",
|
||||||
|
"score": 0.85,
|
||||||
|
"path": "/path/to/file.txt", // for files
|
||||||
|
"board_id": 1, // for deck cards
|
||||||
|
"card_id": 42
|
||||||
|
}],
|
||||||
|
"total_found": 150,
|
||||||
|
"algorithm_used": "hybrid"
|
||||||
|
}
|
||||||
|
|
||||||
|
Requires OAuth bearer token for user filtering.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.vector_sync_enabled:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Vector sync is disabled on this server"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
try:
|
||||||
|
user_id, _validated = await validate_token_and_get_user(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse request body
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
# Validate and parse parameters
|
||||||
|
try:
|
||||||
|
query = body.get("query", "")
|
||||||
|
_validate_query_string(query, max_length=10000)
|
||||||
|
|
||||||
|
limit = _parse_int_param(
|
||||||
|
str(body.get("limit")) if body.get("limit") is not None else None,
|
||||||
|
20,
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
"limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
offset = _parse_int_param(
|
||||||
|
str(body.get("offset")) if body.get("offset") is not None else None,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1000000,
|
||||||
|
"offset",
|
||||||
|
)
|
||||||
|
|
||||||
|
score_threshold = _parse_float_param(
|
||||||
|
body.get("score_threshold"),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
"score_threshold",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=400)
|
||||||
|
|
||||||
|
algorithm = body.get("algorithm", "hybrid")
|
||||||
|
fusion = body.get("fusion", "rrf")
|
||||||
|
include_pca = body.get("include_pca", False)
|
||||||
|
include_chunks = body.get("include_chunks", True)
|
||||||
|
doc_types = body.get("doc_types") # Optional filter
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return JSONResponse({"results": [], "total_found": 0})
|
||||||
|
|
||||||
|
# Validate algorithm
|
||||||
|
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||||
|
if algorithm not in valid_algorithms:
|
||||||
|
algorithm = "hybrid"
|
||||||
|
|
||||||
|
# Validate fusion method
|
||||||
|
valid_fusions = {"rrf", "dbsf"}
|
||||||
|
if fusion not in valid_fusions:
|
||||||
|
fusion = "rrf"
|
||||||
|
|
||||||
|
# Execute search using the appropriate algorithm
|
||||||
|
from nextcloud_mcp_server.search import (
|
||||||
|
BM25HybridSearchAlgorithm,
|
||||||
|
SemanticSearchAlgorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select search algorithm
|
||||||
|
if algorithm == "semantic":
|
||||||
|
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||||
|
else:
|
||||||
|
search_algo = BM25HybridSearchAlgorithm(
|
||||||
|
score_threshold=score_threshold, fusion=fusion
|
||||||
|
)
|
||||||
|
|
||||||
|
# Request extra results to handle offset
|
||||||
|
search_limit = limit + offset
|
||||||
|
|
||||||
|
# Execute search
|
||||||
|
all_results = []
|
||||||
|
if doc_types and isinstance(doc_types, list):
|
||||||
|
for doc_type in doc_types:
|
||||||
|
if doc_type:
|
||||||
|
results = await search_algo.search(
|
||||||
|
query=query,
|
||||||
|
user_id=user_id,
|
||||||
|
limit=search_limit,
|
||||||
|
doc_type=doc_type,
|
||||||
|
)
|
||||||
|
all_results.extend(results)
|
||||||
|
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||||
|
else:
|
||||||
|
all_results = await search_algo.search(
|
||||||
|
query=query,
|
||||||
|
user_id=user_id,
|
||||||
|
limit=search_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort results by score (no deduplication - show all chunks)
|
||||||
|
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
||||||
|
|
||||||
|
# Calculate total and apply pagination
|
||||||
|
total_found = len(sorted_results)
|
||||||
|
paginated_results = sorted_results[offset : offset + limit]
|
||||||
|
|
||||||
|
# Format results for Unified Search
|
||||||
|
formatted_results = []
|
||||||
|
for result in paginated_results:
|
||||||
|
# Get document ID (prefer note_id for notes)
|
||||||
|
doc_id = result.id
|
||||||
|
if result.metadata and "note_id" in result.metadata:
|
||||||
|
doc_id = result.metadata["note_id"]
|
||||||
|
|
||||||
|
result_data: dict[str, Any] = {
|
||||||
|
"id": doc_id,
|
||||||
|
"doc_type": result.doc_type,
|
||||||
|
"title": result.title,
|
||||||
|
"score": result.score,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include excerpt/chunk if requested (full content, no truncation)
|
||||||
|
if include_chunks and result.excerpt:
|
||||||
|
result_data["excerpt"] = result.excerpt
|
||||||
|
|
||||||
|
# Include navigation metadata from result.metadata
|
||||||
|
if result.metadata:
|
||||||
|
# File path and mimetype for files
|
||||||
|
if "path" in result.metadata:
|
||||||
|
result_data["path"] = result.metadata["path"]
|
||||||
|
if "mime_type" in result.metadata:
|
||||||
|
result_data["mime_type"] = result.metadata["mime_type"]
|
||||||
|
|
||||||
|
# Deck card navigation
|
||||||
|
if "board_id" in result.metadata:
|
||||||
|
result_data["board_id"] = result.metadata["board_id"]
|
||||||
|
if "card_id" in result.metadata:
|
||||||
|
result_data["card_id"] = result.metadata["card_id"]
|
||||||
|
|
||||||
|
# Calendar event metadata
|
||||||
|
if "calendar_id" in result.metadata:
|
||||||
|
result_data["calendar_id"] = result.metadata["calendar_id"]
|
||||||
|
if "event_uid" in result.metadata:
|
||||||
|
result_data["event_uid"] = result.metadata["event_uid"]
|
||||||
|
|
||||||
|
# Add PDF page metadata
|
||||||
|
if result.page_number is not None:
|
||||||
|
result_data["page_number"] = result.page_number
|
||||||
|
if result.page_count is not None:
|
||||||
|
result_data["page_count"] = result.page_count
|
||||||
|
|
||||||
|
# Add chunk metadata (always present, defaults to 0 and 1)
|
||||||
|
result_data["chunk_index"] = result.chunk_index
|
||||||
|
result_data["total_chunks"] = result.total_chunks
|
||||||
|
|
||||||
|
# Add chunk offsets for modal navigation
|
||||||
|
if result.chunk_start_offset is not None:
|
||||||
|
result_data["chunk_start_offset"] = result.chunk_start_offset
|
||||||
|
if result.chunk_end_offset is not None:
|
||||||
|
result_data["chunk_end_offset"] = result.chunk_end_offset
|
||||||
|
|
||||||
|
formatted_results.append(result_data)
|
||||||
|
|
||||||
|
response_data: dict[str, Any] = {
|
||||||
|
"results": formatted_results,
|
||||||
|
"total_found": total_found,
|
||||||
|
"algorithm_used": algorithm,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional PCA coordinates
|
||||||
|
if include_pca and len(paginated_results) >= 2:
|
||||||
|
try:
|
||||||
|
from nextcloud_mcp_server.vector.visualization import (
|
||||||
|
compute_pca_coordinates,
|
||||||
|
)
|
||||||
|
|
||||||
|
if search_algo.query_embedding is not None:
|
||||||
|
query_embedding = search_algo.query_embedding
|
||||||
|
else:
|
||||||
|
from nextcloud_mcp_server.embedding.service import (
|
||||||
|
get_embedding_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
embedding_service = get_embedding_service()
|
||||||
|
query_embedding = await embedding_service.embed(query)
|
||||||
|
|
||||||
|
pca_data = await compute_pca_coordinates(
|
||||||
|
paginated_results, query_embedding
|
||||||
|
)
|
||||||
|
response_data["pca_data"] = pca_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to compute PCA for unified search: {e}")
|
||||||
|
|
||||||
|
return JSONResponse(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in unified search: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Internal error",
|
||||||
|
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def vector_search(request: Request) -> JSONResponse:
|
||||||
|
"""POST /api/v1/vector-viz/search - Vector search for visualization.
|
||||||
|
|
||||||
|
Executes semantic search and returns results with optional PCA coordinates
|
||||||
|
for 2D visualization.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"query": "search query",
|
||||||
|
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||||
|
"limit": 10, // max: 50
|
||||||
|
"include_pca": true, // whether to include 2D coordinates
|
||||||
|
"doc_types": ["note", "file"] // optional filter by document types
|
||||||
|
}
|
||||||
|
|
||||||
|
Requires OAuth bearer token for user filtering.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.vector_sync_enabled:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Vector sync is disabled on this server"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
try:
|
||||||
|
user_id, _validated = await validate_token_and_get_user(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "vector_search"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse request body
|
||||||
|
body = await request.json()
|
||||||
|
query = body.get("query", "")
|
||||||
|
algorithm = body.get("algorithm", "hybrid")
|
||||||
|
fusion = body.get("fusion", "rrf")
|
||||||
|
score_threshold = body.get("score_threshold", 0.0)
|
||||||
|
limit = min(body.get("limit", 10), 50) # Enforce max limit
|
||||||
|
include_pca = body.get("include_pca", True)
|
||||||
|
doc_types = body.get("doc_types") # Optional list of document types
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Missing required parameter: query"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate algorithm
|
||||||
|
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||||
|
if algorithm not in valid_algorithms:
|
||||||
|
algorithm = "hybrid"
|
||||||
|
|
||||||
|
# Validate fusion method
|
||||||
|
valid_fusions = {"rrf", "dbsf"}
|
||||||
|
if fusion not in valid_fusions:
|
||||||
|
fusion = "rrf"
|
||||||
|
|
||||||
|
# Execute search using the appropriate algorithm
|
||||||
|
from nextcloud_mcp_server.search import (
|
||||||
|
BM25HybridSearchAlgorithm,
|
||||||
|
SemanticSearchAlgorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select search algorithm
|
||||||
|
if algorithm == "semantic":
|
||||||
|
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||||
|
else:
|
||||||
|
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
|
||||||
|
# which combines dense semantic and sparse BM25 vectors
|
||||||
|
search_algo = BM25HybridSearchAlgorithm(
|
||||||
|
score_threshold=score_threshold, fusion=fusion
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute search for each doc_type if specified, otherwise search all
|
||||||
|
all_results = []
|
||||||
|
if doc_types and isinstance(doc_types, list):
|
||||||
|
# Search each doc_type separately and merge results
|
||||||
|
for doc_type in doc_types:
|
||||||
|
if doc_type: # Skip empty strings
|
||||||
|
results = await search_algo.search(
|
||||||
|
query=query,
|
||||||
|
user_id=user_id,
|
||||||
|
limit=limit,
|
||||||
|
doc_type=doc_type,
|
||||||
|
)
|
||||||
|
all_results.extend(results)
|
||||||
|
# Sort merged results by score and limit
|
||||||
|
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||||
|
all_results = all_results[:limit]
|
||||||
|
else:
|
||||||
|
# Search all document types
|
||||||
|
all_results = await search_algo.search(
|
||||||
|
query=query,
|
||||||
|
user_id=user_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format results for PHP client
|
||||||
|
formatted_results = []
|
||||||
|
for result in all_results:
|
||||||
|
formatted_result = {
|
||||||
|
"id": result.id,
|
||||||
|
"doc_type": result.doc_type,
|
||||||
|
"title": result.title,
|
||||||
|
"excerpt": result.excerpt[:200] if result.excerpt else "",
|
||||||
|
"score": result.score,
|
||||||
|
"metadata": result.metadata,
|
||||||
|
# Chunk information for context display
|
||||||
|
"chunk_index": result.chunk_index,
|
||||||
|
"total_chunks": result.total_chunks,
|
||||||
|
}
|
||||||
|
# Include optional fields if present
|
||||||
|
if result.chunk_start_offset is not None:
|
||||||
|
formatted_result["chunk_start_offset"] = result.chunk_start_offset
|
||||||
|
if result.chunk_end_offset is not None:
|
||||||
|
formatted_result["chunk_end_offset"] = result.chunk_end_offset
|
||||||
|
if result.page_number is not None:
|
||||||
|
formatted_result["page_number"] = result.page_number
|
||||||
|
if result.page_count is not None:
|
||||||
|
formatted_result["page_count"] = result.page_count
|
||||||
|
formatted_results.append(formatted_result)
|
||||||
|
|
||||||
|
response_data: dict[str, Any] = {
|
||||||
|
"results": formatted_results,
|
||||||
|
"algorithm_used": algorithm,
|
||||||
|
"total_documents": len(formatted_results),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compute PCA coordinates for visualization using shared function
|
||||||
|
if include_pca and len(all_results) >= 2:
|
||||||
|
try:
|
||||||
|
from nextcloud_mcp_server.vector.visualization import (
|
||||||
|
compute_pca_coordinates,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get query embedding from search algorithm or generate it
|
||||||
|
if search_algo.query_embedding is not None:
|
||||||
|
query_embedding = search_algo.query_embedding
|
||||||
|
else:
|
||||||
|
from nextcloud_mcp_server.embedding.service import (
|
||||||
|
get_embedding_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
embedding_service = get_embedding_service()
|
||||||
|
query_embedding = await embedding_service.embed(query)
|
||||||
|
|
||||||
|
pca_data = await compute_pca_coordinates(all_results, query_embedding)
|
||||||
|
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
|
||||||
|
response_data["query_coords"] = pca_data["query_coords"]
|
||||||
|
if "pca_variance" in pca_data:
|
||||||
|
response_data["pca_variance"] = pca_data["pca_variance"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to compute PCA coordinates: {e}")
|
||||||
|
response_data["coordinates_3d"] = []
|
||||||
|
response_data["query_coords"] = []
|
||||||
|
elif include_pca:
|
||||||
|
# Not enough results for PCA
|
||||||
|
response_data["coordinates_3d"] = []
|
||||||
|
response_data["query_coords"] = []
|
||||||
|
|
||||||
|
return JSONResponse(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "vector_search")
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chunk_context(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/chunk-context - Fetch chunk text with context.
|
||||||
|
|
||||||
|
Retrieves the matched chunk along with surrounding text and metadata.
|
||||||
|
Used by clients to display chunk context and highlighted PDFs.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
doc_type: Document type (e.g., "note")
|
||||||
|
doc_id: Document ID
|
||||||
|
start: Chunk start offset (character position)
|
||||||
|
end: Chunk end offset (character position)
|
||||||
|
context: Characters of context before/after (default: 500)
|
||||||
|
|
||||||
|
Requires OAuth bearer token for authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
user_id, validated = await validate_token_and_get_user(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "get_chunk_context"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get query parameters
|
||||||
|
doc_type = request.query_params.get("doc_type")
|
||||||
|
doc_id = request.query_params.get("doc_id")
|
||||||
|
start_str = request.query_params.get("start")
|
||||||
|
end_str = request.query_params.get("end")
|
||||||
|
|
||||||
|
# Validate required parameters
|
||||||
|
if not all([doc_type, doc_id, start_str, end_str]):
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing required parameters: doc_type, doc_id, start, end",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type narrowing: we already checked these are not None above
|
||||||
|
assert start_str is not None
|
||||||
|
assert end_str is not None
|
||||||
|
assert doc_id is not None
|
||||||
|
assert doc_type is not None
|
||||||
|
|
||||||
|
# Parse and validate integer parameters with bounds checking
|
||||||
|
try:
|
||||||
|
context_chars = _parse_int_param(
|
||||||
|
request.query_params.get("context"),
|
||||||
|
500,
|
||||||
|
0,
|
||||||
|
10000,
|
||||||
|
"context_chars",
|
||||||
|
)
|
||||||
|
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
|
||||||
|
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
|
||||||
|
if end <= start:
|
||||||
|
raise ValueError("end must be greater than start")
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||||
|
# Convert doc_id to int if possible (most IDs are int)
|
||||||
|
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
|
||||||
|
|
||||||
|
# Get bearer token for client initialization
|
||||||
|
token = extract_bearer_token(request)
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Missing token")
|
||||||
|
|
||||||
|
# Get Nextcloud host from OAuth context
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
# Initialize authenticated Nextcloud client
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
|
|
||||||
|
async with NextcloudClient.from_token(
|
||||||
|
base_url=nextcloud_host, token=token, username=user_id
|
||||||
|
) as nc_client:
|
||||||
|
chunk_context = await get_chunk_with_context(
|
||||||
|
nc_client=nc_client,
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id_val,
|
||||||
|
doc_type=doc_type,
|
||||||
|
chunk_start=start,
|
||||||
|
chunk_end=end,
|
||||||
|
context_chars=context_chars,
|
||||||
|
)
|
||||||
|
|
||||||
|
if chunk_context is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# For PDF files, also fetch the highlighted page image from Qdrant if available
|
||||||
|
# This is useful for clients that want to show a pre-rendered image
|
||||||
|
highlighted_page_image = None
|
||||||
|
page_number = chunk_context.page_number
|
||||||
|
|
||||||
|
if doc_type == "file":
|
||||||
|
try:
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.placeholder import (
|
||||||
|
get_placeholder_filter,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
|
# Query for this specific chunk's highlighted image
|
||||||
|
points_response = await qdrant_client.scroll(
|
||||||
|
collection_name=settings.get_collection_name(),
|
||||||
|
scroll_filter=Filter(
|
||||||
|
must=[
|
||||||
|
get_placeholder_filter(),
|
||||||
|
FieldCondition(
|
||||||
|
key="doc_id", match=MatchValue(value=doc_id_val)
|
||||||
|
),
|
||||||
|
FieldCondition(
|
||||||
|
key="user_id", match=MatchValue(value=user_id)
|
||||||
|
),
|
||||||
|
FieldCondition(
|
||||||
|
key="chunk_start_offset", match=MatchValue(value=start)
|
||||||
|
),
|
||||||
|
FieldCondition(
|
||||||
|
key="chunk_end_offset", match=MatchValue(value=end)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
limit=1,
|
||||||
|
with_vectors=False,
|
||||||
|
with_payload=["highlighted_page_image", "page_number"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if points_response[0]:
|
||||||
|
payload = points_response[0][0].payload
|
||||||
|
if payload:
|
||||||
|
highlighted_page_image = payload.get("highlighted_page_image")
|
||||||
|
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
|
||||||
|
if payload.get("page_number") is not None:
|
||||||
|
page_number = payload.get("page_number")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response_data = {
|
||||||
|
"success": True,
|
||||||
|
"chunk_text": chunk_context.chunk_text,
|
||||||
|
"before_context": chunk_context.before_context,
|
||||||
|
"after_context": chunk_context.after_context,
|
||||||
|
"has_more_before": chunk_context.has_before_truncation,
|
||||||
|
"has_more_after": chunk_context.has_after_truncation,
|
||||||
|
"page_number": page_number,
|
||||||
|
"chunk_index": chunk_context.chunk_index,
|
||||||
|
"total_chunks": chunk_context.total_chunks,
|
||||||
|
}
|
||||||
|
|
||||||
|
if highlighted_page_image:
|
||||||
|
response_data["highlighted_page_image"] = highlighted_page_image
|
||||||
|
|
||||||
|
return JSONResponse(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pdf_preview(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/pdf-preview - Render PDF page to PNG image.
|
||||||
|
|
||||||
|
Server-side PDF rendering using PyMuPDF. This endpoint allows Astrolabe
|
||||||
|
to display PDF pages without requiring client-side PDF.js, avoiding CSP
|
||||||
|
worker restrictions and ES private field issues in Chromium.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
file_path: WebDAV path to PDF file (e.g., "/Documents/report.pdf")
|
||||||
|
page: Page number (1-indexed, default: 1)
|
||||||
|
scale: Zoom factor for rendering (default: 2.0 = 144 DPI)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"image": "<base64-encoded-png>",
|
||||||
|
"page_number": 1,
|
||||||
|
"total_pages": 10
|
||||||
|
}
|
||||||
|
|
||||||
|
Requires OAuth bearer token for authentication.
|
||||||
|
"""
|
||||||
|
# Log incoming request
|
||||||
|
file_path_param = request.query_params.get("file_path", "<not provided>")
|
||||||
|
page_param = request.query_params.get("page", "1")
|
||||||
|
logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
user_id, validated = await validate_token_and_get_user(request)
|
||||||
|
logger.info(f"PDF preview authenticated for user: {user_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "get_pdf_preview"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse and validate parameters
|
||||||
|
file_path = request.query_params.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing required parameter: file_path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate no path traversal sequences
|
||||||
|
if ".." in file_path:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid file path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page_num = _parse_int_param(
|
||||||
|
request.query_params.get("page"), 1, 1, 10000, "page"
|
||||||
|
)
|
||||||
|
scale = _parse_float_param(
|
||||||
|
request.query_params.get("scale"), 2.0, 0.5, 5.0, "scale"
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||||
|
|
||||||
|
# Get bearer token for WebDAV authentication
|
||||||
|
token = extract_bearer_token(request)
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Missing token")
|
||||||
|
|
||||||
|
# Get Nextcloud host from OAuth context
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
# Download PDF via WebDAV using user's token
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
async with NextcloudClient.from_token(
|
||||||
|
base_url=nextcloud_host, token=token, username=user_id
|
||||||
|
) as nc_client:
|
||||||
|
pdf_bytes, _ = await nc_client.webdav.read_file(file_path)
|
||||||
|
|
||||||
|
# Check file size limit (50 MB)
|
||||||
|
max_pdf_size = 50 * 1024 * 1024
|
||||||
|
if len(pdf_bytes) > max_pdf_size:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"PDF file exceeds maximum size limit ({max_pdf_size // (1024 * 1024)} MB)",
|
||||||
|
},
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render page with PyMuPDF
|
||||||
|
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
||||||
|
try:
|
||||||
|
total_pages = doc.page_count
|
||||||
|
|
||||||
|
# Validate page number
|
||||||
|
if page_num > total_pages:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Page {page_num} does not exist (document has {total_pages} pages)",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = doc[page_num - 1] # 0-indexed
|
||||||
|
mat = pymupdf.Matrix(scale, scale)
|
||||||
|
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||||
|
png_bytes = pix.tobytes("png")
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
# Encode as base64
|
||||||
|
image_b64 = base64.b64encode(png_bytes).decode("ascii")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, "
|
||||||
|
f"{len(png_bytes):,} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"image": image_b64,
|
||||||
|
"page_number": page_num,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning(f"PDF file not found: {file_path_param}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "PDF file not found"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
except (pymupdf.FileDataError, pymupdf.EmptyFileError):
|
||||||
|
logger.warning(f"Invalid or corrupted PDF file: {file_path_param}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid or corrupted PDF file"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PDF preview error: {e}", exc_info=True)
|
||||||
|
error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""Webhook management API endpoints.
|
||||||
|
|
||||||
|
Provides REST API endpoints for managing webhook registrations with Nextcloud.
|
||||||
|
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||||
|
- List installed Nextcloud apps
|
||||||
|
- Create, list, and delete webhook registrations
|
||||||
|
|
||||||
|
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.management import (
|
||||||
|
_sanitize_error_for_client,
|
||||||
|
extract_bearer_token,
|
||||||
|
validate_token_and_get_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||||
|
|
||||||
|
Returns a list of installed app IDs for filtering webhook presets.
|
||||||
|
|
||||||
|
Requires OAuth bearer token for authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
user_id, validated = await validate_token_and_get_user(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get Bearer token from request
|
||||||
|
token = extract_bearer_token(request)
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Missing Authorization header")
|
||||||
|
|
||||||
|
# Get Nextcloud host from OAuth context
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
# Create authenticated HTTP client
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
# Get installed apps using OCS API
|
||||||
|
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
|
||||||
|
# We check which ones are installed and enabled
|
||||||
|
ocs_url = "/ocs/v1.php/cloud/apps"
|
||||||
|
params = {"filter": "enabled"}
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
ocs_url,
|
||||||
|
params=params,
|
||||||
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ValueError(f"OCS API returned status {response.status_code}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
|
||||||
|
|
||||||
|
return JSONResponse({"apps": apps})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting installed apps for user {user_id}: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Internal error",
|
||||||
|
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_webhooks(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/webhooks - List all registered webhooks.
|
||||||
|
|
||||||
|
Returns list of webhook registrations for the authenticated user.
|
||||||
|
|
||||||
|
Requires OAuth bearer token for authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
user_id, validated = await validate_token_and_get_user(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
|
# Get Bearer token from request
|
||||||
|
token = extract_bearer_token(request)
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Missing Authorization header")
|
||||||
|
|
||||||
|
# Get Nextcloud host from OAuth context
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
# Create authenticated HTTP client
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
# Use WebhooksClient to list webhooks
|
||||||
|
webhooks_client = WebhooksClient(client, user_id)
|
||||||
|
webhooks = await webhooks_client.list_webhooks()
|
||||||
|
|
||||||
|
return JSONResponse({"webhooks": webhooks})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing webhooks for user {user_id}: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Internal error",
|
||||||
|
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_webhook(request: Request) -> JSONResponse:
|
||||||
|
"""POST /api/v1/webhooks - Create a new webhook registration.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"uri": "http://mcp:8000/webhooks/nextcloud",
|
||||||
|
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns the created webhook data including the webhook ID.
|
||||||
|
|
||||||
|
Requires OAuth bearer token for authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
user_id, validated = await validate_token_and_get_user(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
|
# Parse request body
|
||||||
|
body = await request.json()
|
||||||
|
event = body.get("event")
|
||||||
|
uri = body.get("uri")
|
||||||
|
# Accept both camelCase (eventFilter) and snake_case (event_filter)
|
||||||
|
event_filter = body.get("eventFilter") or body.get("event_filter")
|
||||||
|
|
||||||
|
if not event or not uri:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Bad request",
|
||||||
|
"message": "Missing required fields: event, uri",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Bearer token from request
|
||||||
|
token = extract_bearer_token(request)
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Missing Authorization header")
|
||||||
|
|
||||||
|
# Get Nextcloud host from OAuth context
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
# Create authenticated HTTP client
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
# Use WebhooksClient to create webhook
|
||||||
|
webhooks_client = WebhooksClient(client, user_id)
|
||||||
|
webhook_data = await webhooks_client.create_webhook(
|
||||||
|
event=event, uri=uri, event_filter=event_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse({"webhook": webhook_data})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating webhook for user {user_id}: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Internal error",
|
||||||
|
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_webhook(request: Request) -> JSONResponse:
|
||||||
|
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
|
||||||
|
|
||||||
|
Returns success/failure status.
|
||||||
|
|
||||||
|
Requires OAuth bearer token for authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate OAuth token and extract user
|
||||||
|
user_id, validated = await validate_token_and_get_user(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
|
# Get webhook_id from path parameter
|
||||||
|
webhook_id = request.path_params.get("webhook_id")
|
||||||
|
if not webhook_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Bad request", "message": "Missing webhook_id"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
webhook_id = int(webhook_id)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Bad request", "message": "Invalid webhook_id"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Bearer token from request
|
||||||
|
token = extract_bearer_token(request)
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Missing Authorization header")
|
||||||
|
|
||||||
|
# Get Nextcloud host from OAuth context
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
# Create authenticated HTTP client
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
# Use WebhooksClient to delete webhook
|
||||||
|
webhooks_client = WebhooksClient(client, user_id)
|
||||||
|
await webhooks_client.delete_webhook(webhook_id=webhook_id)
|
||||||
|
|
||||||
|
return JSONResponse({"success": True, "message": "Webhook deleted"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting webhook for user {user_id}: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Internal error",
|
||||||
|
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
@@ -2112,13 +2112,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||||
)
|
)
|
||||||
if enable_management_apis:
|
if enable_management_apis:
|
||||||
from nextcloud_mcp_server.api.management import (
|
from nextcloud_mcp_server.api import (
|
||||||
create_webhook,
|
create_webhook,
|
||||||
delete_app_password,
|
delete_app_password,
|
||||||
delete_webhook,
|
delete_webhook,
|
||||||
get_app_password_status,
|
get_app_password_status,
|
||||||
get_chunk_context,
|
get_chunk_context,
|
||||||
get_installed_apps,
|
get_installed_apps,
|
||||||
|
get_pdf_preview,
|
||||||
get_server_status,
|
get_server_status,
|
||||||
get_user_session,
|
get_user_session,
|
||||||
get_vector_sync_status,
|
get_vector_sync_status,
|
||||||
@@ -2179,6 +2180,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
routes.append(
|
routes.append(
|
||||||
Route("/api/v1/chunk-context", get_chunk_context, methods=["GET"])
|
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
|
# 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/search", unified_search, methods=["POST"]))
|
||||||
routes.append(Route("/api/v1/apps", get_installed_apps, methods=["GET"]))
|
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}/session, /api/v1/users/{user_id}/revoke, "
|
||||||
"/api/v1/users/{user_id}/app-password, "
|
"/api/v1/users/{user_id}/app-password, "
|
||||||
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
"/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
|
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
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"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
|
|||||||
Executable
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database query helper for development.
|
||||||
|
|
||||||
|
Wraps `docker compose exec db mariadb` to execute SQL statements against
|
||||||
|
the Nextcloud MariaDB database.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/dbquery.py "SELECT * FROM oc_notes LIMIT 5"
|
||||||
|
./scripts/dbquery.py -u root -p password "SHOW TABLES"
|
||||||
|
./scripts/dbquery.py --json "SELECT * FROM oc_oidc_clients"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def find_compose_dir() -> Path:
|
||||||
|
"""Find the directory containing docker-compose.yml."""
|
||||||
|
current = Path(__file__).resolve().parent
|
||||||
|
while current != current.parent:
|
||||||
|
if (current / "docker-compose.yml").exists():
|
||||||
|
return current
|
||||||
|
if (current / "compose.yml").exists():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
# Default to script's parent directory
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def run_query(
|
||||||
|
sql: str,
|
||||||
|
user: str = "root",
|
||||||
|
password: str = "password",
|
||||||
|
database: str = "nextcloud",
|
||||||
|
vertical: bool = False,
|
||||||
|
json_output: bool = False,
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""
|
||||||
|
Execute SQL via docker compose exec.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (return_code, stdout, stderr)
|
||||||
|
"""
|
||||||
|
compose_dir = find_compose_dir()
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T", # Disable pseudo-TTY allocation
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
f"-u{user}",
|
||||||
|
f"-p{password}",
|
||||||
|
database,
|
||||||
|
"-e",
|
||||||
|
sql,
|
||||||
|
]
|
||||||
|
|
||||||
|
if vertical:
|
||||||
|
cmd.insert(-2, "-E") # Vertical output format
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=compose_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Execute SQL queries against the Nextcloud MariaDB database",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s "SELECT COUNT(*) FROM oc_notes"
|
||||||
|
%(prog)s "SELECT id, name FROM oc_oidc_clients"
|
||||||
|
%(prog)s -E "SELECT * FROM oc_users LIMIT 1"
|
||||||
|
%(prog)s --user nextcloud --password nextcloud "SHOW TABLES"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("sql", help="SQL statement to execute")
|
||||||
|
parser.add_argument(
|
||||||
|
"-u", "--user", default="root", help="Database user (default: root)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-p",
|
||||||
|
"--password",
|
||||||
|
default="password",
|
||||||
|
help="Database password (default: password)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--database",
|
||||||
|
default="nextcloud",
|
||||||
|
help="Database name (default: nextcloud)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-E",
|
||||||
|
"--vertical",
|
||||||
|
action="store_true",
|
||||||
|
help="Print output vertically (one column per line)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
dest="json_output",
|
||||||
|
help="Request JSON output (if supported)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
returncode, stdout, stderr = run_query(
|
||||||
|
sql=args.sql,
|
||||||
|
user=args.user,
|
||||||
|
password=args.password,
|
||||||
|
database=args.database,
|
||||||
|
vertical=args.vertical,
|
||||||
|
json_output=args.json_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
print(stdout, end="")
|
||||||
|
if stderr:
|
||||||
|
# Filter out the password warning
|
||||||
|
filtered_stderr = "\n".join(
|
||||||
|
line
|
||||||
|
for line in stderr.splitlines()
|
||||||
|
if "Using a password on the command line interface can be insecure"
|
||||||
|
not in line
|
||||||
|
)
|
||||||
|
if filtered_stderr:
|
||||||
|
print(filtered_stderr, file=sys.stderr)
|
||||||
|
|
||||||
|
return returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SQLite database query helper for MCP service development.
|
||||||
|
|
||||||
|
Wraps `docker compose exec <service> sqlite3` to execute SQL statements
|
||||||
|
against the token storage database in any MCP service container.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/sqlitequery.py ".tables"
|
||||||
|
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||||
|
./scripts/sqlitequery.py -s keycloak --headers "SELECT * FROM oauth_clients"
|
||||||
|
./scripts/sqlitequery.py --json "SELECT * FROM audit_logs LIMIT 5"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Service name aliases for convenience
|
||||||
|
SERVICE_ALIASES = {
|
||||||
|
"mcp": "mcp",
|
||||||
|
"oauth": "mcp-oauth",
|
||||||
|
"mcp-oauth": "mcp-oauth",
|
||||||
|
"keycloak": "mcp-keycloak",
|
||||||
|
"mcp-keycloak": "mcp-keycloak",
|
||||||
|
"basic": "mcp-multi-user-basic",
|
||||||
|
"multi-user-basic": "mcp-multi-user-basic",
|
||||||
|
"mcp-multi-user-basic": "mcp-multi-user-basic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_compose_dir() -> Path:
|
||||||
|
"""Find the directory containing docker-compose.yml."""
|
||||||
|
current = Path(__file__).resolve().parent
|
||||||
|
while current != current.parent:
|
||||||
|
if (current / "docker-compose.yml").exists():
|
||||||
|
return current
|
||||||
|
if (current / "compose.yml").exists():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
# Default to script's parent directory
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_service(service: str) -> str:
|
||||||
|
"""Resolve service alias to container name."""
|
||||||
|
resolved = SERVICE_ALIASES.get(service.lower())
|
||||||
|
if resolved is None:
|
||||||
|
# Not a known alias, use as-is (might be a custom service)
|
||||||
|
return service
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def run_query(
|
||||||
|
sql: str,
|
||||||
|
service: str = "mcp",
|
||||||
|
database: str = "/app/data/tokens.db",
|
||||||
|
headers: bool = False,
|
||||||
|
json_output: bool = False,
|
||||||
|
column_mode: bool = False,
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""
|
||||||
|
Execute SQL via docker compose exec.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (return_code, stdout, stderr)
|
||||||
|
"""
|
||||||
|
compose_dir = find_compose_dir()
|
||||||
|
container = resolve_service(service)
|
||||||
|
|
||||||
|
# Build sqlite3 command with options
|
||||||
|
sqlite_args = []
|
||||||
|
|
||||||
|
# Set output mode
|
||||||
|
if json_output:
|
||||||
|
sqlite_args.extend(["-json"])
|
||||||
|
elif column_mode:
|
||||||
|
sqlite_args.extend(["-column"])
|
||||||
|
|
||||||
|
# Enable headers
|
||||||
|
if headers or column_mode:
|
||||||
|
sqlite_args.extend(["-header"])
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T", # Disable pseudo-TTY allocation
|
||||||
|
container,
|
||||||
|
"sqlite3",
|
||||||
|
*sqlite_args,
|
||||||
|
database,
|
||||||
|
sql,
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=compose_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Execute SQL queries against SQLite databases in MCP service containers",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Services:
|
||||||
|
mcp Single-user BasicAuth mode (default)
|
||||||
|
oauth Nextcloud OAuth mode (mcp-oauth)
|
||||||
|
keycloak Keycloak OAuth mode (mcp-keycloak)
|
||||||
|
basic Multi-user BasicAuth mode (mcp-multi-user-basic)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
%(prog)s ".tables"
|
||||||
|
%(prog)s -s oauth "SELECT user_id FROM refresh_tokens"
|
||||||
|
%(prog)s -s keycloak ".schema oauth_clients"
|
||||||
|
%(prog)s --headers "SELECT * FROM audit_logs LIMIT 5"
|
||||||
|
%(prog)s --json "SELECT * FROM oauth_sessions"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("sql", help="SQL statement or SQLite command to execute")
|
||||||
|
parser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--service",
|
||||||
|
default="mcp",
|
||||||
|
help="Target service (mcp, oauth, keycloak, basic) (default: mcp)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--database",
|
||||||
|
default="/app/data/tokens.db",
|
||||||
|
help="Database path inside container (default: /app/data/tokens.db)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--headers",
|
||||||
|
action="store_true",
|
||||||
|
help="Show column headers",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
dest="json_output",
|
||||||
|
help="Output in JSON format",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--column",
|
||||||
|
action="store_true",
|
||||||
|
dest="column_mode",
|
||||||
|
help="Output in column format with headers",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
returncode, stdout, stderr = run_query(
|
||||||
|
sql=args.sql,
|
||||||
|
service=args.service,
|
||||||
|
database=args.database,
|
||||||
|
headers=args.headers,
|
||||||
|
json_output=args.json_output,
|
||||||
|
column_mode=args.column_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
print(stdout, end="")
|
||||||
|
if stderr:
|
||||||
|
print(stderr, file=sys.stderr)
|
||||||
|
|
||||||
|
return returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+31
-22
@@ -2351,32 +2351,41 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error creating editors group (may already exist): {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():
|
for username, config in test_user_configs.items():
|
||||||
|
# Check if user already exists
|
||||||
|
user_exists = False
|
||||||
try:
|
try:
|
||||||
await nc_client.users.create_user(
|
await nc_client.users.get_user_details(username)
|
||||||
userid=username,
|
user_exists = True
|
||||||
password=config["password"],
|
logger.info(f"Test user {username} already exists, skipping creation")
|
||||||
display_name=config["display_name"],
|
except Exception:
|
||||||
email=config["email"],
|
# User doesn't exist, proceed with creation
|
||||||
)
|
pass
|
||||||
logger.info(f"Created test user: {username}")
|
|
||||||
created_users.append(username)
|
|
||||||
|
|
||||||
# Add user to groups if specified
|
if not user_exists:
|
||||||
for group in config["groups"]:
|
try:
|
||||||
try:
|
await nc_client.users.create_user(
|
||||||
await nc_client.users.add_user_to_group(username, group)
|
userid=username,
|
||||||
logger.info(f"Added {username} to group {group}")
|
password=config["password"],
|
||||||
except Exception as e:
|
display_name=config["display_name"],
|
||||||
logger.warning(f"Error adding {username} to group {group}: {e}")
|
email=config["email"],
|
||||||
|
)
|
||||||
|
logger.info(f"Created test user: {username}")
|
||||||
|
created_users.append(username) # Only track users WE created
|
||||||
|
|
||||||
except Exception as e:
|
# Add user to groups if specified
|
||||||
# User might already exist, that's okay
|
for group in config["groups"]:
|
||||||
logger.warning(
|
try:
|
||||||
f"Could not create user {username} (may already exist): {e}"
|
await nc_client.users.add_user_to_group(username, group)
|
||||||
)
|
logger.info(f"Added {username} to group {group}")
|
||||||
created_users.append(username) # Add to list anyway for cleanup
|
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}")
|
logger.info(f"Test users setup complete: {created_users}")
|
||||||
yield test_user_configs
|
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="user"]', username)
|
||||||
await page.fill('input[name="password"]', password)
|
await page.fill('input[name="password"]', password)
|
||||||
|
|
||||||
# Submit form
|
# Submit form - use force=True to bypass stability check (CSS transitions)
|
||||||
await page.click('button[type="submit"]')
|
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)
|
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||||
|
|
||||||
# Verify logged in (should redirect away from login page)
|
# 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")
|
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(
|
async def generate_app_password(
|
||||||
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
|
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -105,16 +399,32 @@ async def generate_app_password(
|
|||||||
await anyio.sleep(1.0)
|
await anyio.sleep(1.0)
|
||||||
logger.info("Waited for Vue.js to process input and enable button")
|
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(
|
create_button = page.locator(
|
||||||
'button[type="submit"]:has-text("Create new app password")'
|
'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")
|
logger.info("Clicked create app password button")
|
||||||
|
|
||||||
# Wait for app password to be generated and displayed in the dialog
|
# Wait for app password to be generated and displayed in the dialog
|
||||||
await anyio.sleep(3) # Give it more time to generate and display
|
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
|
# Find the Login input field which should have the username value
|
||||||
# Then find the Password input field which is in the same form
|
# Then find the Password input field which is in the same form
|
||||||
app_password = None
|
app_password = None
|
||||||
@@ -172,11 +482,11 @@ async def generate_app_password(
|
|||||||
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Close the dialog by clicking the Close button
|
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
|
||||||
close_button = page.get_by_role("button", name="Close")
|
logger.info("Closing app password dialog with Escape key...")
|
||||||
await close_button.click()
|
await page.keyboard.press("Escape")
|
||||||
|
await anyio.sleep(0.5) # Wait for dialog close animation
|
||||||
logger.info("Closed app password dialog")
|
logger.info("Closed app password dialog")
|
||||||
await anyio.sleep(0.5)
|
|
||||||
|
|
||||||
return app_password
|
return app_password
|
||||||
|
|
||||||
@@ -226,9 +536,9 @@ async def enable_background_sync_via_app_password(
|
|||||||
# Wait for page to load
|
# Wait for page to load
|
||||||
await anyio.sleep(1)
|
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:
|
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)
|
active_text = page.get_by_text("Active", exact=True)
|
||||||
if await active_text.is_visible(timeout=2000):
|
if await active_text.is_visible(timeout=2000):
|
||||||
logger.info(f"✓ Background sync already active for {username}")
|
logger.info(f"✓ Background sync already active for {username}")
|
||||||
@@ -236,6 +546,18 @@ async def enable_background_sync_via_app_password(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Find the app password input field using the placeholder text
|
||||||
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||||
app_password_input = page.get_by_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:
|
except Exception:
|
||||||
pass
|
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:
|
try:
|
||||||
active_text = page.get_by_text("Active", exact=True)
|
active_text = page.get_by_text("Active", exact=True)
|
||||||
await active_text.wait_for(timeout=5000, state="visible")
|
if await active_text.count() > 0 and await active_text.is_visible():
|
||||||
logger.info(f"✓ Background sync enabled for {username} - Active badge visible")
|
logger.info(f"✓ Authorization already fully active for {username}")
|
||||||
return True
|
result["step2"] = True
|
||||||
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
# Take screenshot for debugging
|
pass
|
||||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
|
||||||
await page.screenshot(path=screenshot_path)
|
# Step 2: Generate app password and enter it
|
||||||
logger.error(
|
app_password = await generate_app_password(page, username)
|
||||||
f"Active badge did not appear for {username}. Screenshot: {screenshot_path}"
|
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
|
raise
|
||||||
|
|
||||||
|
logger.info(f"✓ Full Astrolabe authorization complete for {username}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def verify_app_password_created(username: str) -> bool:
|
async def verify_app_password_created(username: str) -> bool:
|
||||||
"""Verify that background sync app password was stored for the user.
|
"""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.routing import Route
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
from nextcloud_mcp_server.api import management
|
from nextcloud_mcp_server.api import passwords
|
||||||
from nextcloud_mcp_server.api.management import (
|
from nextcloud_mcp_server.api.passwords import (
|
||||||
delete_app_password,
|
delete_app_password,
|
||||||
get_app_password_status,
|
get_app_password_status,
|
||||||
provision_app_password,
|
provision_app_password,
|
||||||
@@ -32,9 +32,9 @@ pytestmark = pytest.mark.unit
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_rate_limit():
|
def clear_rate_limit():
|
||||||
"""Clear rate limit state before each test."""
|
"""Clear rate limit state before each test."""
|
||||||
management._rate_limit_attempts.clear()
|
passwords._rate_limit_attempts.clear()
|
||||||
yield
|
yield
|
||||||
management._rate_limit_attempts.clear()
|
passwords._rate_limit_attempts.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -199,7 +199,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -406,7 +406,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ async def test_delete_app_password_invalid_credentials(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -515,7 +515,7 @@ async def test_provision_app_password_rate_limiting(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -574,7 +574,7 @@ async def test_rate_limiting_is_per_user(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,716 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Management API PDF preview endpoint.
|
||||||
|
|
||||||
|
Tests the /api/v1/pdf-preview endpoint focusing on:
|
||||||
|
- Parameter validation (file_path, page, scale)
|
||||||
|
- OAuth token validation
|
||||||
|
- PDF rendering with PyMuPDF
|
||||||
|
- Error handling (file not found, invalid page, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.visualization import get_pdf_preview
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_app():
|
||||||
|
"""Create a test Starlette app with the PDF preview endpoint."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Set up OAuth context (required by endpoint)
|
||||||
|
app.state.oauth_context = {"config": {"nextcloud_host": "http://localhost:8080"}}
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_pdf_bytes():
|
||||||
|
"""Create a minimal valid PDF for testing."""
|
||||||
|
# Minimal PDF structure that PyMuPDF can parse
|
||||||
|
# This is a 1-page PDF with a blank page
|
||||||
|
pdf_content = b"""%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<< /Type /Catalog /Pages 2 0 R >>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer
|
||||||
|
<< /Size 4 /Root 1 0 R >>
|
||||||
|
startxref
|
||||||
|
196
|
||||||
|
%%EOF"""
|
||||||
|
return pdf_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfPreviewParameterValidation:
|
||||||
|
"""Tests for parameter validation in PDF preview endpoint."""
|
||||||
|
|
||||||
|
def test_missing_file_path_returns_400(self):
|
||||||
|
"""Test that missing file_path parameter returns 400."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "file_path" in data["error"].lower()
|
||||||
|
|
||||||
|
def test_invalid_page_number_returns_400(self):
|
||||||
|
"""Test that invalid page number (0 or negative) returns 400."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Test page=0
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&page=0",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "page" in data["error"].lower()
|
||||||
|
|
||||||
|
# Test negative page
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&page=-1",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_invalid_scale_returns_400(self):
|
||||||
|
"""Test that scale outside valid range returns 400."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Test scale too small
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.1",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "scale" in data["error"].lower()
|
||||||
|
|
||||||
|
# Test scale too large
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&scale=10.0",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_non_numeric_page_returns_400(self):
|
||||||
|
"""Test that non-numeric page parameter returns 400."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&page=abc",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfPreviewAuthentication:
|
||||||
|
"""Tests for authentication in PDF preview endpoint."""
|
||||||
|
|
||||||
|
def test_unauthorized_without_token_returns_401(self):
|
||||||
|
"""Test that request without token returns 401."""
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Invalid token"),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/pdf-preview?file_path=/test.pdf")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
|
||||||
|
def test_unauthorized_with_invalid_token_returns_401(self):
|
||||||
|
"""Test that request with invalid token returns 401."""
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Token expired"),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||||
|
headers={"Authorization": "Bearer invalid-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfPreviewRendering:
|
||||||
|
"""Tests for PDF rendering functionality."""
|
||||||
|
|
||||||
|
def test_successful_pdf_render(self):
|
||||||
|
"""Test successful PDF page rendering."""
|
||||||
|
pdf_bytes = create_mock_pdf_bytes()
|
||||||
|
|
||||||
|
# Mock the WebDAV client
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&page=1&scale=1.0",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "image" in data
|
||||||
|
assert data["page_number"] == 1
|
||||||
|
assert data["total_pages"] == 1
|
||||||
|
|
||||||
|
# Verify image is valid base64
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(data["image"])
|
||||||
|
# PNG magic bytes
|
||||||
|
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Image is not valid base64-encoded PNG: {e}")
|
||||||
|
|
||||||
|
def test_page_out_of_range_returns_400(self):
|
||||||
|
"""Test that requesting page beyond total pages returns 400."""
|
||||||
|
pdf_bytes = create_mock_pdf_bytes()
|
||||||
|
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&page=999",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "page" in data["error"].lower()
|
||||||
|
assert "999" in data["error"]
|
||||||
|
|
||||||
|
def test_file_not_found_returns_404(self):
|
||||||
|
"""Test that non-existent file returns 404."""
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(
|
||||||
|
side_effect=FileNotFoundError("File not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/nonexistent.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "not found" in data["error"].lower()
|
||||||
|
|
||||||
|
def test_default_parameters(self):
|
||||||
|
"""Test that default parameters (page=1, scale=2.0) are used."""
|
||||||
|
pdf_bytes = create_mock_pdf_bytes()
|
||||||
|
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
# Only file_path, no page or scale
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["page_number"] == 1 # Default page
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfPreviewEdgeCases:
|
||||||
|
"""Tests for edge cases in PDF preview endpoint."""
|
||||||
|
|
||||||
|
def test_url_encoded_file_path(self):
|
||||||
|
"""Test that URL-encoded file paths are handled correctly."""
|
||||||
|
pdf_bytes = create_mock_pdf_bytes()
|
||||||
|
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
# URL-encoded path with spaces
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/Documents/My%20File.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Verify the path was passed correctly to WebDAV
|
||||||
|
mock_webdav.read_file.assert_called_once()
|
||||||
|
call_args = mock_webdav.read_file.call_args[0]
|
||||||
|
assert "My File.pdf" in call_args[0]
|
||||||
|
|
||||||
|
def test_missing_nextcloud_host_config(self):
|
||||||
|
"""Test handling when Nextcloud host is not configured."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
# Override with empty config
|
||||||
|
app.state.oauth_context = {"config": {"nextcloud_host": ""}}
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
|
||||||
|
def test_corrupted_pdf_returns_400(self):
|
||||||
|
"""Test that corrupted PDF data returns 400 with specific error."""
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
# Return invalid PDF bytes
|
||||||
|
mock_webdav.read_file = AsyncMock(
|
||||||
|
return_value=(b"not a valid pdf", "application/pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert (
|
||||||
|
"corrupted" in data["error"].lower()
|
||||||
|
or "invalid" in data["error"].lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_boundary_scale_values(self):
|
||||||
|
"""Test boundary scale values (min and max)."""
|
||||||
|
pdf_bytes = create_mock_pdf_bytes()
|
||||||
|
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Test minimum valid scale (0.5)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.5",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test maximum valid scale (5.0)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/test.pdf&scale=5.0",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfPreviewSecurityValidation:
|
||||||
|
"""Tests for security validations in PDF preview endpoint."""
|
||||||
|
|
||||||
|
def test_path_traversal_returns_400(self):
|
||||||
|
"""Test that path traversal attempts are blocked with 400."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Test various path traversal patterns
|
||||||
|
traversal_paths = [
|
||||||
|
"/Documents/../../../etc/passwd",
|
||||||
|
"/../secret.pdf",
|
||||||
|
"/folder/..%2F..%2Fetc/passwd", # URL-encoded
|
||||||
|
"/test/../secret.pdf",
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in traversal_paths:
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/pdf-preview?file_path={path}",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, (
|
||||||
|
f"Path traversal not blocked: {path}"
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "invalid file path" in data["error"].lower()
|
||||||
|
|
||||||
|
def test_file_size_limit_exceeded_returns_413(self):
|
||||||
|
"""Test that files exceeding 50MB limit return 413."""
|
||||||
|
# Create bytes larger than 50MB limit
|
||||||
|
large_pdf_bytes = b"x" * (51 * 1024 * 1024) # 51 MB
|
||||||
|
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(
|
||||||
|
return_value=(large_pdf_bytes, "application/pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/large.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 413
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "size limit" in data["error"].lower()
|
||||||
|
|
||||||
|
def test_corrupted_pdf_returns_400(self):
|
||||||
|
"""Test that corrupted PDF returns 400 with specific error message."""
|
||||||
|
# Invalid PDF content that PyMuPDF cannot parse
|
||||||
|
corrupted_pdf_bytes = b"not a valid PDF file content"
|
||||||
|
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(
|
||||||
|
return_value=(corrupted_pdf_bytes, "application/pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert (
|
||||||
|
"corrupted" in data["error"].lower()
|
||||||
|
or "invalid" in data["error"].lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_pdf_returns_400(self):
|
||||||
|
"""Test that empty PDF file returns 400."""
|
||||||
|
empty_pdf_bytes = b""
|
||||||
|
|
||||||
|
mock_webdav = AsyncMock()
|
||||||
|
mock_webdav.read_file = AsyncMock(
|
||||||
|
return_value=(empty_pdf_bytes, "application/pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_nc_client = MagicMock()
|
||||||
|
mock_nc_client.webdav = mock_webdav
|
||||||
|
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||||
|
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("testuser", True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||||
|
return_value="test-token",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||||
|
return_value=mock_nc_client,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/pdf-preview?file_path=/empty.pdf",
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.8.3"
|
version = "0.9.0"
|
||||||
tag_format = "astrolabe-v$version"
|
tag_format = "astrolabe-v$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
Vendored
+22
@@ -25,6 +25,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Requires external MCP server deployment
|
- Requires external MCP server deployment
|
||||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
- 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)
|
## astrolabe-v0.8.3 (2026-01-17)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+4
-1
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
|||||||
|
|
||||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||||
]]></description>
|
]]></description>
|
||||||
<version>0.8.3</version>
|
<version>0.9.0</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||||
<namespace>Astrolabe</namespace>
|
<namespace>Astrolabe</namespace>
|
||||||
@@ -57,4 +57,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
|
|||||||
<type>link</type>
|
<type>link</type>
|
||||||
</navigation>
|
</navigation>
|
||||||
</navigations>
|
</navigations>
|
||||||
|
<background-jobs>
|
||||||
|
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
||||||
|
</background-jobs>
|
||||||
</info>
|
</info>
|
||||||
|
|||||||
+5
@@ -72,6 +72,11 @@ return [
|
|||||||
'url' => '/api/chunk-context',
|
'url' => '/api/chunk-context',
|
||||||
'verb' => 'GET',
|
'verb' => 'GET',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'api#pdfPreview',
|
||||||
|
'url' => '/api/pdf-preview',
|
||||||
|
'verb' => 'GET',
|
||||||
|
],
|
||||||
|
|
||||||
// Admin settings routes
|
// Admin settings routes
|
||||||
[
|
[
|
||||||
|
|||||||
Vendored
+1
@@ -39,6 +39,7 @@
|
|||||||
"php": "^8.1"
|
"php": "^8.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"doctrine/dbal": "^3.8",
|
||||||
"nextcloud/ocp": "dev-stable30",
|
"nextcloud/ocp": "dev-stable30",
|
||||||
"phpunit/phpunit": "^10.0",
|
"phpunit/phpunit": "^10.0",
|
||||||
"roave/security-advisories": "dev-latest"
|
"roave/security-advisories": "dev-latest"
|
||||||
|
|||||||
+303
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "94a9d7f7619235ef2a310deec2ce14f0",
|
"content-hash": "e6ea5a770c578a5d7694602bb2618cef",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bamarni/composer-bin-plugin",
|
"name": "bamarni/composer-bin-plugin",
|
||||||
@@ -65,6 +65,259 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"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",
|
"name": "myclabs/deep-copy",
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
@@ -775,6 +1028,55 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-06T07:50:42+00:00"
|
"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",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"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
@@ -152,10 +152,11 @@ class ApiController extends Controller {
|
|||||||
$userId = $user->getUID();
|
$userId = $user->getUID();
|
||||||
|
|
||||||
// Create refresh callback that calls IdP directly
|
// 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);
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
if (!$newTokenData) {
|
if ($newTokenData === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ class ApiController extends Controller {
|
|||||||
|
|
||||||
// Get user's OAuth token for MCP server with automatic refresh
|
// Get user's OAuth token for MCP server with automatic refresh
|
||||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||||
if (!$accessToken) {
|
if ($accessToken === null) {
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'MCP server authorization required. Please authorize the app first.'
|
'error' => 'MCP server authorization required. Please authorize the app first.'
|
||||||
@@ -417,10 +418,11 @@ class ApiController extends Controller {
|
|||||||
$userId = $user->getUID();
|
$userId = $user->getUID();
|
||||||
|
|
||||||
// Create refresh callback
|
// 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);
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
if (!$newTokenData) {
|
if ($newTokenData === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +435,7 @@ class ApiController extends Controller {
|
|||||||
|
|
||||||
// Get access token with automatic refresh
|
// Get access token with automatic refresh
|
||||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||||
if (!$accessToken) {
|
if ($accessToken === null) {
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'MCP server authorization required'
|
'error' => 'MCP server authorization required'
|
||||||
@@ -529,10 +531,11 @@ class ApiController extends Controller {
|
|||||||
$userId = $user->getUID();
|
$userId = $user->getUID();
|
||||||
|
|
||||||
// Create refresh callback
|
// 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);
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
if (!$newTokenData) {
|
if ($newTokenData === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +548,7 @@ class ApiController extends Controller {
|
|||||||
|
|
||||||
// Get access token with automatic refresh
|
// Get access token with automatic refresh
|
||||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||||
if (!$accessToken) {
|
if ($accessToken === null) {
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'MCP server authorization required'
|
'error' => 'MCP server authorization required'
|
||||||
@@ -628,10 +631,11 @@ class ApiController extends Controller {
|
|||||||
$userId = $user->getUID();
|
$userId = $user->getUID();
|
||||||
|
|
||||||
// Create refresh callback
|
// 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);
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
if (!$newTokenData) {
|
if ($newTokenData === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +648,7 @@ class ApiController extends Controller {
|
|||||||
|
|
||||||
// Get access token with automatic refresh
|
// Get access token with automatic refresh
|
||||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||||
if (!$accessToken) {
|
if ($accessToken === null) {
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'MCP server authorization required'
|
'error' => 'MCP server authorization required'
|
||||||
@@ -757,10 +761,11 @@ class ApiController extends Controller {
|
|||||||
$userId = $user->getUID();
|
$userId = $user->getUID();
|
||||||
|
|
||||||
// Create refresh callback
|
// 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);
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
if (!$newTokenData) {
|
if ($newTokenData === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,7 +778,7 @@ class ApiController extends Controller {
|
|||||||
|
|
||||||
// Get user's OAuth token for MCP server with automatic refresh
|
// Get user's OAuth token for MCP server with automatic refresh
|
||||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||||
if (!$accessToken) {
|
if ($accessToken === null) {
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'MCP server authorization required.'
|
'error' => 'MCP server authorization required.'
|
||||||
@@ -788,4 +793,62 @@ class ApiController extends Controller {
|
|||||||
|
|
||||||
return new JSONResponse($result);
|
return new JSONResponse($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get PDF page preview (server-side rendered).
|
||||||
|
*
|
||||||
|
* AJAX endpoint for PDF viewer in semantic search UI.
|
||||||
|
* Uses server-side PyMuPDF rendering to avoid CSP/worker issues.
|
||||||
|
*
|
||||||
|
* @param string $file_path WebDAV path to PDF file
|
||||||
|
* @param int $page Page number (1-indexed, default: 1)
|
||||||
|
* @param float $scale Zoom factor (default: 2.0)
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
#[NoAdminRequired]
|
||||||
|
public function pdfPreview(
|
||||||
|
string $file_path,
|
||||||
|
int $page = 1,
|
||||||
|
float $scale = 2.0,
|
||||||
|
): JSONResponse {
|
||||||
|
$user = $this->userSession->getUser();
|
||||||
|
if (!$user) {
|
||||||
|
return new JSONResponse(['success' => false, 'error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $user->getUID();
|
||||||
|
|
||||||
|
// Create refresh callback
|
||||||
|
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||||
|
$refreshCallback = function (string $refreshToken): ?array {
|
||||||
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
|
if ($newTokenData === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'access_token' => $newTokenData['access_token'],
|
||||||
|
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||||
|
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's OAuth token for MCP server with automatic refresh
|
||||||
|
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||||
|
if ($accessToken === null) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'MCP server authorization required.'
|
||||||
|
], Http::STATUS_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->client->getPdfPreview($file_path, $page, $scale, $accessToken);
|
||||||
|
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JSONResponse($result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface;
|
|||||||
* - Public clients: PKCE only
|
* - Public clients: PKCE only
|
||||||
* - Confidential clients: PKCE + client_secret (defense in depth)
|
* - Confidential clients: PKCE + client_secret (defense in depth)
|
||||||
*/
|
*/
|
||||||
class OAuthController extends Controller {
|
class OauthController extends Controller {
|
||||||
private IConfig $config;
|
private IConfig $config;
|
||||||
private ISession $session;
|
private ISession $session;
|
||||||
private IUserSession $userSession;
|
private IUserSession $userSession;
|
||||||
+23
-3
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace OCA\Astrolabe\Search;
|
namespace OCA\Astrolabe\Search;
|
||||||
|
|
||||||
use OCA\Astrolabe\AppInfo\Application;
|
use OCA\Astrolabe\AppInfo\Application;
|
||||||
|
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||||
use OCA\Astrolabe\Service\McpServerClient;
|
use OCA\Astrolabe\Service\McpServerClient;
|
||||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||||
@@ -35,6 +36,7 @@ class SemanticSearchProvider implements IProvider {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private McpServerClient $client,
|
private McpServerClient $client,
|
||||||
private McpTokenStorage $tokenStorage,
|
private McpTokenStorage $tokenStorage,
|
||||||
|
private IdpTokenRefresher $tokenRefresher,
|
||||||
private IConfig $config,
|
private IConfig $config,
|
||||||
private IL10N $l10n,
|
private IL10N $l10n,
|
||||||
private IURLGenerator $urlGenerator,
|
private IURLGenerator $urlGenerator,
|
||||||
@@ -85,12 +87,30 @@ class SemanticSearchProvider implements IProvider {
|
|||||||
return SearchResult::complete($this->getName(), []);
|
return SearchResult::complete($this->getName(), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get OAuth token for user
|
$userId = $user->getUID();
|
||||||
$accessToken = $this->tokenStorage->getAccessToken($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) {
|
if ($accessToken === null) {
|
||||||
// User hasn't authorized the app yet - return empty results
|
// User hasn't authorized the app yet - return empty results
|
||||||
$this->logger->debug('No OAuth token for user in semantic search', [
|
$this->logger->debug('No OAuth token for user in semantic search', [
|
||||||
'user_id' => $user->getUID(),
|
'user_id' => $userId,
|
||||||
]);
|
]);
|
||||||
return SearchResult::complete($this->getName(), []);
|
return SearchResult::complete($this->getName(), []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -605,4 +605,62 @@ class McpServerClient {
|
|||||||
return ['error' => $e->getMessage()];
|
return ['error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get PDF page preview (server-side rendered).
|
||||||
|
*
|
||||||
|
* Renders a PDF page to PNG using PyMuPDF on the server.
|
||||||
|
* This avoids client-side PDF.js issues with CSP and ES private fields.
|
||||||
|
*
|
||||||
|
* Requires OAuth bearer token for authentication.
|
||||||
|
*
|
||||||
|
* @param string $filePath WebDAV path to PDF file
|
||||||
|
* @param int $page Page number (1-indexed)
|
||||||
|
* @param float $scale Zoom factor (default: 2.0)
|
||||||
|
* @param string $token OAuth bearer token
|
||||||
|
* @return array{
|
||||||
|
* success?: bool,
|
||||||
|
* image?: string,
|
||||||
|
* page_number?: int,
|
||||||
|
* total_pages?: int,
|
||||||
|
* error?: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getPdfPreview(
|
||||||
|
string $filePath,
|
||||||
|
int $page,
|
||||||
|
float $scale,
|
||||||
|
string $token,
|
||||||
|
): array {
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->get(
|
||||||
|
$this->baseUrl . '/api/v1/pdf-preview',
|
||||||
|
[
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer ' . $token
|
||||||
|
],
|
||||||
|
'query' => [
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'page' => $page,
|
||||||
|
'scale' => $scale,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
/** @var array{success?: bool, image?: string, page_number?: int, total_pages?: int, error?: string} $data */
|
||||||
|
$data = json_decode((string)$response->getBody(), true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new \RuntimeException('Invalid JSON response from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Failed to get PDF preview', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'page' => $page,
|
||||||
|
]);
|
||||||
|
return ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-33
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace OCA\Astrolabe\Service;
|
namespace OCA\Astrolabe\Service;
|
||||||
|
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
use OCP\Lock\ILockingProvider;
|
||||||
|
use OCP\Lock\LockedException;
|
||||||
use OCP\Security\ICrypto;
|
use OCP\Security\ICrypto;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
@@ -20,16 +23,22 @@ class McpTokenStorage {
|
|||||||
|
|
||||||
private $config;
|
private $config;
|
||||||
private $crypto;
|
private $crypto;
|
||||||
|
private $db;
|
||||||
private $logger;
|
private $logger;
|
||||||
|
private ILockingProvider $lockingProvider;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IConfig $config,
|
IConfig $config,
|
||||||
ICrypto $crypto,
|
ICrypto $crypto,
|
||||||
|
IDBConnection $db,
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
|
ILockingProvider $lockingProvider,
|
||||||
) {
|
) {
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
$this->crypto = $crypto;
|
$this->crypto = $crypto;
|
||||||
|
$this->db = $db;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
$this->lockingProvider = $lockingProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,18 +50,21 @@ class McpTokenStorage {
|
|||||||
* @param string $accessToken OAuth access token
|
* @param string $accessToken OAuth access token
|
||||||
* @param string $refreshToken OAuth refresh token
|
* @param string $refreshToken OAuth refresh token
|
||||||
* @param int $expiresAt Unix timestamp when token expires
|
* @param int $expiresAt Unix timestamp when token expires
|
||||||
|
* @param int|null $issuedAt Unix timestamp when token was issued (for lifetime calculation)
|
||||||
*/
|
*/
|
||||||
public function storeUserToken(
|
public function storeUserToken(
|
||||||
string $userId,
|
string $userId,
|
||||||
string $accessToken,
|
string $accessToken,
|
||||||
string $refreshToken,
|
string $refreshToken,
|
||||||
int $expiresAt,
|
int $expiresAt,
|
||||||
|
?int $issuedAt = null,
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
$tokenData = [
|
$tokenData = [
|
||||||
'access_token' => $accessToken,
|
'access_token' => $accessToken,
|
||||||
'refresh_token' => $refreshToken,
|
'refresh_token' => $refreshToken,
|
||||||
'expires_at' => $expiresAt,
|
'expires_at' => $expiresAt,
|
||||||
|
'issued_at' => $issuedAt ?? time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Encrypt token data before storage
|
// Encrypt token data before storage
|
||||||
@@ -129,6 +141,42 @@ class McpTokenStorage {
|
|||||||
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
|
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.
|
* 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.
|
* Get the access token for a user, handling expiration and refresh.
|
||||||
*
|
*
|
||||||
* This is a convenience method that combines token retrieval,
|
* This is a convenience method that combines token retrieval,
|
||||||
* expiration checking, and automatic refresh if needed.
|
* 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 string $userId User ID
|
||||||
* @param callable|null $refreshCallback Callback to refresh token if expired
|
* @param callable|null $refreshCallback Callback to refresh token if expired
|
||||||
* Should accept (refreshToken) and return new token data
|
* Should accept (refreshToken) and return new token data
|
||||||
* @return string|null Access token, or null if not available
|
* @return string|null Access token, or null if not available
|
||||||
*/
|
*/
|
||||||
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
||||||
|
// Quick check without lock (optimization)
|
||||||
$token = $this->getUserToken($userId);
|
$token = $this->getUserToken($userId);
|
||||||
|
|
||||||
if (!$token) {
|
if (!$token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired
|
// If not expired, return immediately without lock
|
||||||
if ($this->isExpired($token)) {
|
if (!$this->isExpired($token)) {
|
||||||
// Try to refresh if callback provided
|
return $token['access_token'];
|
||||||
if ($refreshCallback && isset($token['refresh_token'])) {
|
}
|
||||||
try {
|
|
||||||
$newTokenData = $refreshCallback($token['refresh_token']);
|
|
||||||
|
|
||||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
// Token expired - acquire lock for refresh
|
||||||
// Store refreshed token
|
try {
|
||||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
/**
|
||||||
$this->storeUserToken(
|
* @return string|null
|
||||||
$userId,
|
* @psalm-suppress MixedInferredReturnType
|
||||||
$newTokenData['access_token'],
|
*/
|
||||||
$newTokenData['refresh_token'] ?? $token['refresh_token'],
|
return $this->withTokenLock($userId, function () use ($userId, $refreshCallback): ?string {
|
||||||
time() + ($newTokenData['expires_in'] ?? 3600)
|
// 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'];
|
if ($currentToken === null) {
|
||||||
}
|
|
||||||
} 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;
|
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->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;
|
return null;
|
||||||
}
|
});
|
||||||
|
} catch (LockedException $e) {
|
||||||
// Token expired and no refresh callback available - delete stale token
|
// Could not acquire lock - another process is refreshing
|
||||||
$this->deleteUserToken($userId);
|
// Return stale token rather than failing - caller can retry if needed
|
||||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
$this->logger->warning("Could not acquire token lock for user $userId, returning stale token");
|
||||||
return null;
|
/** @var string|null $staleToken */
|
||||||
|
$staleToken = $token['access_token'] ?? null;
|
||||||
|
return $staleToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $token['access_token'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -86,7 +86,9 @@ class Personal implements ISettings {
|
|||||||
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
|
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
|
||||||
// Check both credentials
|
// Check both credentials
|
||||||
$hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token));
|
$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);
|
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nextcloud/axios": "^2.5.1",
|
"@nextcloud/axios": "^2.5.1",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"@nextcloud/vue": "^9.3.3",
|
"@nextcloud/vue": "^9.3.3",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"pdfjs-dist": "^4.0.379",
|
"pdfjs-dist": "^4.0.379",
|
||||||
"plotly.js-dist-min": "^2.35.3",
|
"plotly.js-dist-min": "^3.0.0",
|
||||||
"vue": "^3.0.0",
|
"vue": "^3.0.0",
|
||||||
"vue-material-design-icons": "^5.3.1"
|
"vue-material-design-icons": "^5.3.1"
|
||||||
},
|
},
|
||||||
@@ -7905,7 +7905,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/plotly.js-dist-min": {
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
|
|||||||
Vendored
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.8.3",
|
"version": "0.9.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^22.0.0",
|
||||||
@@ -25,8 +25,7 @@
|
|||||||
"@nextcloud/router": "^3.0.1",
|
"@nextcloud/router": "^3.0.1",
|
||||||
"@nextcloud/vue": "^9.3.3",
|
"@nextcloud/vue": "^9.3.3",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"pdfjs-dist": "^4.0.379",
|
"plotly.js-dist-min": "^3.0.0",
|
||||||
"plotly.js-dist-min": "^2.35.3",
|
|
||||||
"vue": "^3.0.0",
|
"vue": "^3.0.0",
|
||||||
"vue-material-design-icons": "^5.3.1"
|
"vue-material-design-icons": "^5.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-26
@@ -13,13 +13,6 @@
|
|||||||
<code><![CDATA[$result['query_coords']]]></code>
|
<code><![CDATA[$result['query_coords']]]></code>
|
||||||
<code><![CDATA[$webhook['eventFilter']]]></code>
|
<code><![CDATA[$webhook['eventFilter']]]></code>
|
||||||
</InvalidArrayOffset>
|
</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>
|
<MixedArgument>
|
||||||
<code><![CDATA[!empty($eventConfig['filter']) ? $eventConfig['filter'] : null]]></code>
|
<code><![CDATA[!empty($eventConfig['filter']) ? $eventConfig['filter'] : null]]></code>
|
||||||
<code><![CDATA[$accessToken]]></code>
|
<code><![CDATA[$accessToken]]></code>
|
||||||
@@ -62,16 +55,6 @@
|
|||||||
<code><![CDATA[$webhook['id']]]></code>
|
<code><![CDATA[$webhook['id']]]></code>
|
||||||
</PossiblyUndefinedArrayOffset>
|
</PossiblyUndefinedArrayOffset>
|
||||||
<RiskyTruthyFalsyComparison>
|
<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[!$token]]></code>
|
||||||
<code><![CDATA[empty($webhook['eventFilter'])]]></code>
|
<code><![CDATA[empty($webhook['eventFilter'])]]></code>
|
||||||
</RiskyTruthyFalsyComparison>
|
</RiskyTruthyFalsyComparison>
|
||||||
@@ -106,7 +89,7 @@
|
|||||||
<code><![CDATA[CredentialsController]]></code>
|
<code><![CDATA[CredentialsController]]></code>
|
||||||
</UnusedClass>
|
</UnusedClass>
|
||||||
</file>
|
</file>
|
||||||
<file src="lib/Controller/OAuthController.php">
|
<file src="lib/Controller/OauthController.php">
|
||||||
<MixedArgument>
|
<MixedArgument>
|
||||||
<code><![CDATA[$authEndpoint]]></code>
|
<code><![CDATA[$authEndpoint]]></code>
|
||||||
<code><![CDATA[$codeVerifier]]></code>
|
<code><![CDATA[$codeVerifier]]></code>
|
||||||
@@ -175,7 +158,7 @@
|
|||||||
<code><![CDATA[$error]]></code>
|
<code><![CDATA[$error]]></code>
|
||||||
</RiskyTruthyFalsyComparison>
|
</RiskyTruthyFalsyComparison>
|
||||||
<UnusedClass>
|
<UnusedClass>
|
||||||
<code><![CDATA[OAuthController]]></code>
|
<code><![CDATA[OauthController]]></code>
|
||||||
</UnusedClass>
|
</UnusedClass>
|
||||||
</file>
|
</file>
|
||||||
<file src="lib/Listener/AstrolabeAdminSettingsListener.php">
|
<file src="lib/Listener/AstrolabeAdminSettingsListener.php">
|
||||||
@@ -405,11 +388,6 @@
|
|||||||
<InvalidReturnType>
|
<InvalidReturnType>
|
||||||
<code><![CDATA[array|null]]></code>
|
<code><![CDATA[array|null]]></code>
|
||||||
</InvalidReturnType>
|
</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>
|
<MixedAssignment>
|
||||||
<code><![CDATA[$newTokenData]]></code>
|
<code><![CDATA[$newTokenData]]></code>
|
||||||
</MixedAssignment>
|
</MixedAssignment>
|
||||||
@@ -417,11 +395,9 @@
|
|||||||
<code><![CDATA[string|null]]></code>
|
<code><![CDATA[string|null]]></code>
|
||||||
</MixedInferredReturnType>
|
</MixedInferredReturnType>
|
||||||
<MixedOperand>
|
<MixedOperand>
|
||||||
<code><![CDATA[$newTokenData['expires_in'] ?? 3600]]></code>
|
|
||||||
<code><![CDATA[$token['expires_at']]]></code>
|
<code><![CDATA[$token['expires_at']]]></code>
|
||||||
</MixedOperand>
|
</MixedOperand>
|
||||||
<MixedReturnStatement>
|
<MixedReturnStatement>
|
||||||
<code><![CDATA[$newTokenData['access_token']]]></code>
|
|
||||||
<code><![CDATA[$token['access_token']]]></code>
|
<code><![CDATA[$token['access_token']]]></code>
|
||||||
</MixedReturnStatement>
|
</MixedReturnStatement>
|
||||||
<PossiblyUnusedMethod>
|
<PossiblyUnusedMethod>
|
||||||
|
|||||||
Vendored
+27
-19
@@ -394,18 +394,6 @@ import MarkdownViewer from './components/MarkdownViewer.vue'
|
|||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import Plotly from 'plotly.js-dist-min'
|
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 {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
@@ -615,7 +603,20 @@ export default {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Search error:', 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 = []
|
this.results = []
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
@@ -637,7 +638,14 @@ export default {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Status error:', 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 {
|
} finally {
|
||||||
this.statusLoading = false
|
this.statusLoading = false
|
||||||
}
|
}
|
||||||
@@ -749,7 +757,7 @@ export default {
|
|||||||
colorscale: 'Viridis',
|
colorscale: 'Viridis',
|
||||||
showscale: true,
|
showscale: true,
|
||||||
colorbar: {
|
colorbar: {
|
||||||
title: 'Relative Score',
|
title: { text: 'Relative Score' },
|
||||||
x: 1.02,
|
x: 1.02,
|
||||||
xanchor: 'left',
|
xanchor: 'left',
|
||||||
thickness: 20,
|
thickness: 20,
|
||||||
@@ -784,13 +792,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const layout = {
|
const layout = {
|
||||||
title: `Vector Space (PCA 3D) - ${results.length} results`,
|
title: { text: `Vector Space (PCA 3D) - ${results.length} results` },
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
scene: {
|
scene: {
|
||||||
xaxis: { title: 'PC1' },
|
xaxis: { title: { text: 'PC1' } },
|
||||||
yaxis: { title: 'PC2' },
|
yaxis: { title: { text: 'PC2' } },
|
||||||
zaxis: { title: 'PC3' },
|
zaxis: { title: { text: 'PC3' } },
|
||||||
camera: {
|
camera: {
|
||||||
eye: { x: 1.5, y: 1.5, z: 1.5 },
|
eye: { x: 1.5, y: 1.5, z: 1.5 },
|
||||||
},
|
},
|
||||||
|
|||||||
+65
-111
@@ -8,15 +8,28 @@
|
|||||||
<AlertCircle :size="48" />
|
<AlertCircle :size="48" />
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else ref="containerRef" class="pdf-canvas-container">
|
<div v-else class="pdf-image-container">
|
||||||
<canvas ref="canvasRef" />
|
<img
|
||||||
|
:src="`data:image/png;base64,${imageData}`"
|
||||||
|
class="pdf-page-image"
|
||||||
|
alt="PDF page" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { generateUrl } from '@nextcloud/router'
|
||||||
import { translate as t } from '@nextcloud/l10n'
|
import { translate as t } from '@nextcloud/l10n'
|
||||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||||
@@ -33,61 +46,68 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
scale: {
|
scale: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 1.5,
|
default: 2.0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
|
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const pdfDoc = ref(null)
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
const imageData = ref(null)
|
||||||
const totalPages = ref(0)
|
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
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Clean and encode the file path
|
// Build request URL
|
||||||
const cleanPath = props.filePath.startsWith('/')
|
const url = generateUrl('/apps/astrolabe/api/pdf-preview')
|
||||||
? props.filePath.substring(1)
|
const params = {
|
||||||
: props.filePath
|
file_path: props.filePath,
|
||||||
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
|
page: props.pageNumber,
|
||||||
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
|
scale: props.scale,
|
||||||
|
}
|
||||||
|
|
||||||
// Load PDF document
|
const response = await axios.get(url, { params })
|
||||||
const loadingTask = pdfjsLib.getDocument({
|
|
||||||
url: downloadUrl,
|
|
||||||
withCredentials: true,
|
|
||||||
useWorkerFetch: false, // Disable worker fetch for CSP compliance
|
|
||||||
isEvalSupported: false, // Disable eval for CSP
|
|
||||||
})
|
|
||||||
|
|
||||||
pdfDoc.value = await loadingTask.promise
|
if (!response.data.success) {
|
||||||
totalPages.value = pdfDoc.value.numPages
|
throw new Error(response.data.error || 'Failed to load PDF page')
|
||||||
emit('loaded', { totalPages: totalPages.value })
|
}
|
||||||
|
|
||||||
|
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
|
loading.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PDF load error:', err)
|
console.error('PDF load error:', err)
|
||||||
|
|
||||||
// Provide user-friendly error messages
|
// Provide user-friendly error messages based on axios error structure
|
||||||
if (err.name === 'MissingPDFException') {
|
const status = err.response?.status
|
||||||
|
const serverError = err.response?.data?.error
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
error.value = t('astrolabe', 'PDF file not found')
|
error.value = t('astrolabe', 'PDF file not found')
|
||||||
} else if (err.name === 'InvalidPDFException') {
|
} else if (status === 401 || status === 403) {
|
||||||
error.value = t('astrolabe', 'Invalid or corrupted PDF file')
|
error.value = serverError || t('astrolabe', 'Authorization required to view PDF')
|
||||||
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
|
} else if (err.code === 'ERR_NETWORK' || err.message?.includes('Network')) {
|
||||||
error.value = t('astrolabe', 'Network error loading PDF')
|
error.value = t('astrolabe', 'Network error loading PDF')
|
||||||
} else if (err.message?.includes('404')) {
|
} else if (serverError) {
|
||||||
error.value = t('astrolabe', 'PDF file not found')
|
error.value = serverError
|
||||||
} else {
|
} else {
|
||||||
error.value = t('astrolabe', 'Unable to load PDF file')
|
error.value = t('astrolabe', 'Unable to load PDF page')
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('error', err)
|
emit('error', err)
|
||||||
@@ -95,78 +115,12 @@ async function loadPDF() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPage(pageNum) {
|
// Re-fetch when file path or page number changes
|
||||||
if (!pdfDoc.value) {
|
watch(() => [props.filePath, props.pageNumber], loadPage)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Initial load
|
||||||
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
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPDF()
|
loadPage()
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (pdfDoc.value) {
|
|
||||||
pdfDoc.value.destroy()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -206,19 +160,19 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-canvas-container {
|
.pdf-image-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
background: var(--color-main-background);
|
background: var(--color-main-background);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
.pdf-page-image {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
+3
-2
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use OCA\Astrolabe\AppInfo\Application;
|
||||||
use OCP\Util;
|
use OCP\Util;
|
||||||
|
|
||||||
Util::addScript(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main');
|
Util::addScript(Application::APP_ID, Application::APP_ID . '-main');
|
||||||
Util::addStyle(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\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;
|
namespace OCA\Astrolabe\Tests\Unit\Service;
|
||||||
|
|
||||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||||
|
use OCP\DB\IResult;
|
||||||
|
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||||
|
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
use OCP\Lock\ILockingProvider;
|
||||||
|
use OCP\Lock\LockedException;
|
||||||
use OCP\Security\ICrypto;
|
use OCP\Security\ICrypto;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -19,7 +25,9 @@ use Psr\Log\LoggerInterface;
|
|||||||
final class McpTokenStorageTest extends TestCase {
|
final class McpTokenStorageTest extends TestCase {
|
||||||
private IConfig&MockObject $config;
|
private IConfig&MockObject $config;
|
||||||
private ICrypto&MockObject $crypto;
|
private ICrypto&MockObject $crypto;
|
||||||
|
private IDBConnection&MockObject $db;
|
||||||
private LoggerInterface&MockObject $logger;
|
private LoggerInterface&MockObject $logger;
|
||||||
|
private ILockingProvider&MockObject $lockingProvider;
|
||||||
private McpTokenStorage $storage;
|
private McpTokenStorage $storage;
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
@@ -27,12 +35,16 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
|
|
||||||
$this->config = $this->createMock(IConfig::class);
|
$this->config = $this->createMock(IConfig::class);
|
||||||
$this->crypto = $this->createMock(ICrypto::class);
|
$this->crypto = $this->createMock(ICrypto::class);
|
||||||
|
$this->db = $this->createMock(IDBConnection::class);
|
||||||
$this->logger = $this->createMock(LoggerInterface::class);
|
$this->logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$this->lockingProvider = $this->createMock(ILockingProvider::class);
|
||||||
|
|
||||||
$this->storage = new McpTokenStorage(
|
$this->storage = new McpTokenStorage(
|
||||||
$this->config,
|
$this->config,
|
||||||
$this->crypto,
|
$this->crypto,
|
||||||
$this->logger
|
$this->db,
|
||||||
|
$this->logger,
|
||||||
|
$this->lockingProvider
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +58,15 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
$refreshToken = 'refresh-token-456';
|
$refreshToken = 'refresh-token-456';
|
||||||
$expiresAt = time() + 3600;
|
$expiresAt = time() + 3600;
|
||||||
|
|
||||||
$expectedTokenData = [
|
|
||||||
'access_token' => $accessToken,
|
|
||||||
'refresh_token' => $refreshToken,
|
|
||||||
'expires_at' => $expiresAt,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->crypto->expects($this->once())
|
$this->crypto->expects($this->once())
|
||||||
->method('encrypt')
|
->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');
|
->willReturn('encrypted-data');
|
||||||
|
|
||||||
$this->config->expects($this->once())
|
$this->config->expects($this->once())
|
||||||
@@ -284,6 +296,155 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
$this->assertNull($result);
|
$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)
|
// App Password Storage Tests (Multi-User Basic Auth)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -524,4 +685,145 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
|
|
||||||
$this->assertNull($result);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.61.5"
|
version = "0.62.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user