Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e93097137 | |||
| 0eae33a918 | |||
| 3430b2409d | |||
| adde0e5623 | |||
| 12c96af819 | |||
| d86a185e04 | |||
| f4759e424d | |||
| 1bced88c97 | |||
| b58e7238ae |
@@ -9,3 +9,4 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Generated by pytest used to login users
|
# Generated by pytest used to login users
|
||||||
.nextcloud_oauth_*.json
|
.nextcloud_oauth_*.json
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@
|
|||||||
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
||||||
- implement MCP sampling for semantic search RAG (ADR-008)
|
- implement MCP sampling for semantic search RAG (ADR-008)
|
||||||
- add optional vector database and semantic search to helm chart
|
- add optional vector database and semantic search to helm chart
|
||||||
- add vector sync processing status to /user/page endpoint
|
- add vector sync processing status to /app endpoint
|
||||||
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
||||||
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -88,13 +88,13 @@ services:
|
|||||||
- VECTOR_SYNC_SCAN_INTERVAL=10
|
- VECTOR_SYNC_SCAN_INTERVAL=10
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
- LOG_FORMAT=text
|
#- LOG_FORMAT=json
|
||||||
|
|
||||||
# Qdrant configuration (three modes):
|
# Qdrant configuration (three modes):
|
||||||
# 1. Network mode: Set QDRANT_URL=http://qdrant:6333 (requires qdrant service)
|
# 1. Network mode: Set QDRANT_URL=http://qdrant:6333 (requires qdrant service)
|
||||||
# 2. In-memory mode: Set QDRANT_LOCATION=:memory: (default if nothing set)
|
# 2. In-memory mode: Set QDRANT_LOCATION=:memory: (default if nothing set)
|
||||||
# 3. Persistent local: Set QDRANT_LOCATION=/app/data/qdrant (stored in mcp-data volume)
|
# 3. Persistent local: Set QDRANT_LOCATION=/app/data/qdrant (stored in mcp-data volume)
|
||||||
- QDRANT_LOCATION=":memory:" # In-memory mode for CI/testing (no external service required)
|
#- QDRANT_LOCATION=/app/data/qdrant # In-memory mode used if not set
|
||||||
#- QDRANT_URL=http://qdrant:6333 # Uncomment for network mode
|
#- QDRANT_URL=http://qdrant:6333 # Uncomment for network mode
|
||||||
#- QDRANT_API_KEY=${QDRANT_API_KEY:-my_secret_api_key} # Only for network mode
|
#- QDRANT_API_KEY=${QDRANT_API_KEY:-my_secret_api_key} # Only for network mode
|
||||||
|
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ async def get_vector_sync_status(ctx: Context) -> dict:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The web UI (`/user/page` route) mirrors these controls with a simple toggle switch for enabling/disabling sync and a status display showing indexed counts and sync state. There is no job history, no detailed progress bars, no per-document status—just the essential information users need.
|
The web UI (`/app` route) mirrors these controls with a simple toggle switch for enabling/disabling sync and a status display showing indexed counts and sync state. There is no job history, no detailed progress bars, no per-document status—just the essential information users need.
|
||||||
|
|
||||||
### Authentication and Offline Access
|
### Authentication and Offline Access
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ The webhook_listeners app supports events for all Nextcloud apps relevant to thi
|
|||||||
**Files/Notes Events** (notes are stored as files):
|
**Files/Notes Events** (notes are stored as files):
|
||||||
- `OCP\Files\Events\Node\NodeCreatedEvent`
|
- `OCP\Files\Events\Node\NodeCreatedEvent`
|
||||||
- `OCP\Files\Events\Node\NodeWrittenEvent`
|
- `OCP\Files\Events\Node\NodeWrittenEvent`
|
||||||
- `OCP\Files\Events\Node\NodeDeletedEvent`
|
- `OCP\Files\Events\Node\BeforeNodeDeletedEvent` ⭐ **Use this for deletion (includes node.id)**
|
||||||
|
- `OCP\Files\Events\Node\NodeDeletedEvent` (missing node.id - file already deleted)
|
||||||
- `OCP\Files\Events\Node\NodeRenamedEvent`
|
- `OCP\Files\Events\Node\NodeRenamedEvent`
|
||||||
- `OCP\Files\Events\Node\NodeCopiedEvent`
|
- `OCP\Files\Events\Node\NodeCopiedEvent`
|
||||||
|
|
||||||
@@ -228,8 +229,9 @@ def extract_document_task(event_class: str, payload: dict) -> DocumentTask | Non
|
|||||||
modified_at=event_data["objectData"]["lastmodified"],
|
modified_at=event_data["objectData"]["lastmodified"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deletion events
|
# Deletion events (use BeforeNodeDeletedEvent for files to get node.id)
|
||||||
elif "NodeDeletedEvent" in event_class or \
|
elif "BeforeNodeDeletedEvent" in event_class or \
|
||||||
|
"NodeDeletedEvent" in event_class or \
|
||||||
"CalendarObjectDeletedEvent" in event_class:
|
"CalendarObjectDeletedEvent" in event_class:
|
||||||
# Similar logic for delete operations
|
# Similar logic for delete operations
|
||||||
...
|
...
|
||||||
@@ -412,9 +414,248 @@ async def test_webhook_integration_mocked_delivery():
|
|||||||
|
|
||||||
**Deduplication Window**: Track recently processed documents (last 5 minutes) to avoid redundant work when webhooks and scanner both detect the same change. The processor can check a simple in-memory cache before fetching document content.
|
**Deduplication Window**: Track recently processed documents (last 5 minutes) to avoid redundant work when webhooks and scanner both detect the same change. The processor can check a simple in-memory cache before fetching document content.
|
||||||
|
|
||||||
|
## Appendix A: Manual Webhook Testing Results (2025-01-11)
|
||||||
|
|
||||||
|
### Testing Summary
|
||||||
|
|
||||||
|
Manual validation of Nextcloud webhook schemas and behavior confirmed that webhooks work as documented with several important findings for implementation. **5 out of 6** webhook types were successfully captured and validated.
|
||||||
|
|
||||||
|
**Test Environment:**
|
||||||
|
- Nextcloud 30+ (Docker compose)
|
||||||
|
- webhook_listeners app enabled
|
||||||
|
- Test endpoint: `http://mcp:8000/webhooks/nextcloud`
|
||||||
|
- Background webhook worker running (60s timeout)
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
- ✅ NodeCreatedEvent (file creation)
|
||||||
|
- ✅ NodeWrittenEvent (file update)
|
||||||
|
- ✅ NodeDeletedEvent (file deletion)
|
||||||
|
- ✅ CalendarObjectCreatedEvent
|
||||||
|
- ✅ CalendarObjectUpdatedEvent
|
||||||
|
- ❌ CalendarObjectDeletedEvent (webhook did not fire - potential Nextcloud bug)
|
||||||
|
|
||||||
|
### Critical Implementation Findings
|
||||||
|
|
||||||
|
#### 1. Deletion Events Lack `node.id` Field
|
||||||
|
|
||||||
|
**Finding:** `NodeDeletedEvent` payloads do NOT include `event.node.id`, only `event.node.path`.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {"uid": "admin", "displayName": "admin"},
|
||||||
|
"time": 1762851093,
|
||||||
|
"event": {
|
||||||
|
"class": "OCP\\Files\\Events\\Node\\NodeDeletedEvent",
|
||||||
|
"node": {
|
||||||
|
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
|
||||||
|
// NOTE: No "id" field present
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** The event parser in this ADR's example code assumes `event_data["node"]["id"]` exists for all file events. This will fail for deletions.
|
||||||
|
|
||||||
|
**Update (2025-11-11):** Nextcloud maintainer clarified that `BeforeNodeDeletedEvent` should be used instead of `NodeDeletedEvent` to access `node.id` before the file is deleted. See [issue #56371](https://github.com/nextcloud/server/issues/56371#issuecomment-2470896634).
|
||||||
|
|
||||||
|
> "Try using the `BeforeNodeDeletedEvent`. The `id` should still be available at that time. The reason `id` is not in `NodeDeletedEvent` is because the file is effectively guaranteed to be gone and, in turn, so is the FileInfo."
|
||||||
|
> — Josh Richards, Nextcloud maintainer
|
||||||
|
|
||||||
|
**Recommended Solution:** Use `OCP\Files\Events\Node\BeforeNodeDeletedEvent` for file deletion webhooks instead of `NodeDeletedEvent`.
|
||||||
|
|
||||||
|
**Alternative Fix (if using NodeDeletedEvent):** Check for `id` existence and fall back to path-based identification:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def extract_document_task(event_class: str, payload: dict) -> DocumentTask | None:
|
||||||
|
user_id = payload["user"]["uid"]
|
||||||
|
event_data = payload["event"]
|
||||||
|
|
||||||
|
# File deletion events - NO node.id field
|
||||||
|
if "NodeDeletedEvent" in event_class:
|
||||||
|
path = event_data["node"]["path"]
|
||||||
|
if not path.endswith(".md"):
|
||||||
|
return None
|
||||||
|
# Use path-based ID since node.id is unavailable
|
||||||
|
return DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=f"path:{path}", # Prefix to distinguish from numeric IDs
|
||||||
|
doc_type="note",
|
||||||
|
operation="delete",
|
||||||
|
modified_at=payload["time"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# File creation/update events - node.id exists
|
||||||
|
elif "NodeCreatedEvent" in event_class or "NodeWrittenEvent" in event_class:
|
||||||
|
path = event_data["node"]["path"]
|
||||||
|
if not path.endswith(".md"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if 'id' exists (should, but be defensive)
|
||||||
|
node_id = event_data["node"].get("id")
|
||||||
|
if not node_id:
|
||||||
|
# Fallback for missing ID
|
||||||
|
node_id = f"path:{path}"
|
||||||
|
|
||||||
|
return DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=str(node_id),
|
||||||
|
doc_type="note",
|
||||||
|
operation="index",
|
||||||
|
modified_at=payload["time"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Qdrant Deletion Strategy:** When deleting by path-based ID, search Qdrant for documents with matching path metadata:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def delete_document_by_path(user_id: str, path: str):
|
||||||
|
"""Delete document from Qdrant using path (when ID unavailable)."""
|
||||||
|
points = await qdrant.scroll(
|
||||||
|
collection_name=collection,
|
||||||
|
scroll_filter=Filter(must=[
|
||||||
|
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||||
|
FieldCondition(key="metadata.path", match=MatchValue(value=path)),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
# Delete found points...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Multiple Webhooks Per Operation
|
||||||
|
|
||||||
|
**Finding:** Creating a single note triggers 3-5 separate webhook events in rapid succession:
|
||||||
|
|
||||||
|
1. `NodeCreatedEvent` for parent folder (if new)
|
||||||
|
2. `NodeWrittenEvent` for parent folder
|
||||||
|
3. `NodeCreatedEvent` for the note file
|
||||||
|
4. `NodeWrittenEvent` for the note file (sometimes fires twice)
|
||||||
|
|
||||||
|
**Impact:** Without deduplication, the processor will fetch and index the same note multiple times within seconds, wasting compute and API quota.
|
||||||
|
|
||||||
|
**Solution:** The processor queue should be idempotent. If the same document is queued multiple times, only the latest version needs processing. Implementation options:
|
||||||
|
|
||||||
|
1. **Queue-level deduplication:** Before adding to queue, check if a task for the same `(user_id, doc_id)` is already pending. Replace the existing task instead of adding duplicate.
|
||||||
|
|
||||||
|
2. **Processor-level deduplication:** Track recently processed documents in a short-lived cache (5 minutes). If a document was just processed, skip redundant fetch unless the `modified_at` timestamp is newer.
|
||||||
|
|
||||||
|
3. **Accept duplicates:** Let the processor handle duplicates naturally. Qdrant upserts are idempotent—reindexing with identical content is harmless but wasteful.
|
||||||
|
|
||||||
|
**Recommendation:** Implement queue-level deduplication by maintaining a map of pending tasks and replacing duplicates with newer timestamps.
|
||||||
|
|
||||||
|
#### 3. Type Discrepancy in `node.id`
|
||||||
|
|
||||||
|
**Finding:** Nextcloud documentation specifies `node.id` as type `string`, but actual payloads return `int`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"node": {
|
||||||
|
"id": 437, // integer, not "437"
|
||||||
|
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Code that assumes `node.id` is always a string will work but may cause type confusion in strongly-typed languages.
|
||||||
|
|
||||||
|
**Solution:** Explicitly convert to string when extracting: `doc_id=str(event_data["node"]["id"])`
|
||||||
|
|
||||||
|
#### 4. Calendar Events Have Different ID Field Path
|
||||||
|
|
||||||
|
**Finding:** Calendar events store the document ID in a different location than file events:
|
||||||
|
|
||||||
|
- **File events:** `event.node.id`
|
||||||
|
- **Calendar events:** `event.objectData.id`
|
||||||
|
|
||||||
|
**Impact:** Event parser must handle different field paths for different event types. The example code in this ADR correctly shows this difference.
|
||||||
|
|
||||||
|
**Calendar Event Deletion:** Calendar deletion webhooks did NOT fire during testing. This may be a Nextcloud bug or require specific configuration (e.g., trash bin enabled). Until resolved, calendar deletions will only be detected via periodic scanner runs.
|
||||||
|
|
||||||
|
#### 5. Rich Metadata in Calendar Webhooks
|
||||||
|
|
||||||
|
**Finding:** Calendar webhook payloads include extensive metadata not present in file webhooks:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"calendarId": 1,
|
||||||
|
"calendarData": {
|
||||||
|
"id": 1,
|
||||||
|
"uri": "personal",
|
||||||
|
"{http://calendarserver.org/ns/}getctag": "...",
|
||||||
|
"{http://sabredav.org/ns}sync-token": 21,
|
||||||
|
// ... many calendar-level properties
|
||||||
|
},
|
||||||
|
"objectData": {
|
||||||
|
"id": 3,
|
||||||
|
"uri": "webhook-test-event-001.ics",
|
||||||
|
"lastmodified": 1762851169,
|
||||||
|
"etag": "\"2b937b7d77dc83c77329dfdb210ba9d0\"",
|
||||||
|
"calendarid": 1,
|
||||||
|
"size": 297,
|
||||||
|
"component": "vevent",
|
||||||
|
"classification": 0,
|
||||||
|
"uid": "webhook-test-event-001@nextcloud",
|
||||||
|
"calendardata": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...", // Full iCal
|
||||||
|
"{http://nextcloud.com/ns}deleted-at": null
|
||||||
|
},
|
||||||
|
"shares": [] // Array of sharing info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opportunity:** The full iCal content is available in `objectData.calendardata`. The processor could extract metadata directly from the webhook payload instead of making an additional CalDAV request, reducing API load.
|
||||||
|
|
||||||
|
### Updated Event Mapping
|
||||||
|
|
||||||
|
Based on testing, the actual webhook behavior:
|
||||||
|
|
||||||
|
| Nextcloud Event | Fires? | `node.id`/`objectData.id` Present? | Notes |
|
||||||
|
|----------------|--------|-------------------------------------|-------|
|
||||||
|
| `NodeCreatedEvent` | ✅ Yes | ✅ Yes (`int`) | Fires for folders too |
|
||||||
|
| `NodeWrittenEvent` | ✅ Yes | ✅ Yes (`int`) | Fires 1-2x per operation |
|
||||||
|
| `NodeDeletedEvent` | ✅ Yes | ❌ **NO** (only `path`) | Critical difference |
|
||||||
|
| `CalendarObjectCreatedEvent` | ✅ Yes | ✅ Yes (`objectData.id`) | Full iCal included |
|
||||||
|
| `CalendarObjectUpdatedEvent` | ✅ Yes | ✅ Yes (`objectData.id`) | Full iCal included |
|
||||||
|
| `CalendarObjectDeletedEvent` | ❌ **DID NOT FIRE** | ❓ Unknown | Possible Nextcloud bug |
|
||||||
|
|
||||||
|
### Recommended Implementation Changes
|
||||||
|
|
||||||
|
The webhook handler code in this ADR requires these modifications:
|
||||||
|
|
||||||
|
1. **Handle missing `node.id` in deletions** (see code example in Finding #1)
|
||||||
|
2. **Add deduplication logic** to prevent redundant processing from multiple webhooks per operation
|
||||||
|
3. **Validate field existence** before accessing nested properties (`get()` with defaults)
|
||||||
|
4. **Log unsupported events** at DEBUG level (not WARNING) to avoid log noise
|
||||||
|
5. **Add calendar deletion fallback:** Since webhook unreliable, calendar deletions rely on scanner reconciliation
|
||||||
|
6. **Consider payload optimization:** Extract calendar metadata from webhook payload to reduce CalDAV API calls
|
||||||
|
|
||||||
|
### Testing Implications
|
||||||
|
|
||||||
|
**Integration Test Strategy:**
|
||||||
|
|
||||||
|
The asynchronous nature of Nextcloud webhooks makes real webhook delivery unreliable for automated tests:
|
||||||
|
|
||||||
|
- ✅ **DO:** POST webhook payloads directly to `/webhooks/nextcloud` endpoint in tests
|
||||||
|
- ❌ **DON'T:** Trigger Nextcloud events and wait for webhook delivery
|
||||||
|
- ✅ **DO:** Test authentication, payload parsing, and queue integration with mocked payloads
|
||||||
|
- ❌ **DON'T:** Assume webhooks fire immediately or reliably
|
||||||
|
|
||||||
|
**Manual Testing Required:**
|
||||||
|
- Real webhook delivery latency (depends on background job workers)
|
||||||
|
- Calendar deletion webhook behavior (confirm bug or configuration issue)
|
||||||
|
- Behavior under high-frequency updates (bulk operations)
|
||||||
|
- Network failure handling (Nextcloud can't reach MCP server)
|
||||||
|
|
||||||
|
### Complete Tested Payload Examples
|
||||||
|
|
||||||
|
See `webhook-testing-findings.md` in the repository root for:
|
||||||
|
- Complete JSON payloads for all tested events
|
||||||
|
- Detailed schema validation results
|
||||||
|
- Additional edge cases and observations
|
||||||
|
- Screenshots of webhook logs
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-007: Background Vector Database Synchronization (polling architecture)
|
- ADR-007: Background Vector Database Synchronization (polling architecture)
|
||||||
- Nextcloud Documentation: `~/Software/documentation/admin_manual/webhook_listeners/index.rst`
|
- Nextcloud Documentation: `~/Software/documentation/admin_manual/webhook_listeners/index.rst`
|
||||||
- Nextcloud OCS API: Webhook registration endpoint
|
- Nextcloud OCS API: Webhook registration endpoint
|
||||||
- Current scanner implementation: `nextcloud_mcp_server/vector/scanner.py:37`
|
- Current scanner implementation: `nextcloud_mcp_server/vector/scanner.py:37`
|
||||||
|
- Webhook Testing Report: `webhook-testing-findings.md` (2025-01-11)
|
||||||
|
|||||||
+99
-265
@@ -8,13 +8,12 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import click
|
import click
|
||||||
import httpx
|
import httpx
|
||||||
import uvicorn
|
|
||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
@@ -22,7 +21,7 @@ from pydantic import AnyHttpUrl
|
|||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.routing import Mount, Route
|
from starlette.routing import Mount, Route
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import (
|
from nextcloud_mcp_server.auth import (
|
||||||
@@ -42,7 +41,6 @@ from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
|||||||
from nextcloud_mcp_server.document_processors import get_registry
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
from nextcloud_mcp_server.observability import (
|
from nextcloud_mcp_server.observability import (
|
||||||
ObservabilityMiddleware,
|
ObservabilityMiddleware,
|
||||||
get_uvicorn_logging_config,
|
|
||||||
setup_metrics,
|
setup_metrics,
|
||||||
setup_tracing,
|
setup_tracing,
|
||||||
)
|
)
|
||||||
@@ -219,6 +217,7 @@ class AppContext:
|
|||||||
"""Application context for BasicAuth mode."""
|
"""Application context for BasicAuth mode."""
|
||||||
|
|
||||||
client: NextcloudClient
|
client: NextcloudClient
|
||||||
|
storage: Optional["RefreshTokenStorage"] = None
|
||||||
document_send_stream: Optional[MemoryObjectSendStream] = None
|
document_send_stream: Optional[MemoryObjectSendStream] = None
|
||||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||||
shutdown_event: Optional[anyio.Event] = None
|
shutdown_event: Optional[anyio.Event] = None
|
||||||
@@ -292,7 +291,7 @@ async def load_oauth_client_credentials(
|
|||||||
|
|
||||||
# Try loading from SQLite storage
|
# Try loading from SQLite storage
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
@@ -346,7 +345,7 @@ async def load_oauth_client_credentials(
|
|||||||
|
|
||||||
# Ensure OAuth client in SQLite storage
|
# Ensure OAuth client in SQLite storage
|
||||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
@@ -396,6 +395,13 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
client = NextcloudClient.from_env()
|
client = NextcloudClient.from_env()
|
||||||
logger.info("Client initialization complete")
|
logger.info("Client initialization complete")
|
||||||
|
|
||||||
|
# Initialize persistent storage (for webhook tracking and future features)
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
storage = RefreshTokenStorage.from_env()
|
||||||
|
await storage.initialize()
|
||||||
|
logger.info("Persistent storage initialized (webhook tracking enabled)")
|
||||||
|
|
||||||
# Initialize document processors
|
# Initialize document processors
|
||||||
initialize_document_processors()
|
initialize_document_processors()
|
||||||
|
|
||||||
@@ -450,6 +456,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
try:
|
try:
|
||||||
yield AppContext(
|
yield AppContext(
|
||||||
client=client,
|
client=client,
|
||||||
|
storage=storage,
|
||||||
document_send_stream=send_stream,
|
document_send_stream=send_stream,
|
||||||
document_receive_stream=receive_stream,
|
document_receive_stream=receive_stream,
|
||||||
shutdown_event=shutdown_event,
|
shutdown_event=shutdown_event,
|
||||||
@@ -466,7 +473,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
else:
|
else:
|
||||||
# No vector sync - simple lifecycle
|
# No vector sync - simple lifecycle
|
||||||
try:
|
try:
|
||||||
yield AppContext(client=client)
|
yield AppContext(client=client, storage=storage)
|
||||||
finally:
|
finally:
|
||||||
logger.info("Shutting down BasicAuth mode")
|
logger.info("Shutting down BasicAuth mode")
|
||||||
await client.close()
|
await client.close()
|
||||||
@@ -583,7 +590,7 @@ async def setup_oauth_config():
|
|||||||
refresh_token_storage = None
|
refresh_token_storage = None
|
||||||
if enable_offline_access:
|
if enable_offline_access:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import (
|
from nextcloud_mcp_server.auth.storage import (
|
||||||
RefreshTokenStorage,
|
RefreshTokenStorage,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1031,7 +1038,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
# browser_app is in the same function scope (defined later in create_app)
|
# browser_app is in the same function scope (defined later in create_app)
|
||||||
# We need to find it in the mounted routes
|
# We need to find it in the mounted routes
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, Mount) and route.path == "/user":
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
route.app.state.oauth_context = oauth_context_dict
|
route.app.state.oauth_context = oauth_context_dict
|
||||||
logger.info(
|
logger.info(
|
||||||
"OAuth context shared with browser_app for session auth"
|
"OAuth context shared with browser_app for session auth"
|
||||||
@@ -1041,6 +1048,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# BasicAuth mode - share storage with browser_app for webhook management
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
storage = RefreshTokenStorage.from_env()
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
app.state.storage = storage
|
||||||
|
|
||||||
|
# Also share with browser_app for webhook routes
|
||||||
|
for route in app.routes:
|
||||||
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
|
route.app.state.storage = storage
|
||||||
|
logger.info(
|
||||||
|
"Storage shared with browser_app for webhook management"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
||||||
# For streamable-http transport, FastMCP lifespan isn't automatically triggered
|
# For streamable-http transport, FastMCP lifespan isn't automatically triggered
|
||||||
@@ -1075,15 +1099,15 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
app.state.shutdown_event = shutdown_event
|
app.state.shutdown_event = shutdown_event
|
||||||
app.state.scanner_wake_event = scanner_wake_event
|
app.state.scanner_wake_event = scanner_wake_event
|
||||||
|
|
||||||
# Also share with browser_app for /user/page route
|
# Also share with browser_app for /app route
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, Mount) and route.path == "/user":
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
route.app.state.document_send_stream = send_stream
|
route.app.state.document_send_stream = send_stream
|
||||||
route.app.state.document_receive_stream = receive_stream
|
route.app.state.document_receive_stream = receive_stream
|
||||||
route.app.state.shutdown_event = shutdown_event
|
route.app.state.shutdown_event = shutdown_event
|
||||||
route.app.state.scanner_wake_event = scanner_wake_event
|
route.app.state.scanner_wake_event = scanner_wake_event
|
||||||
logger.info(
|
logger.info(
|
||||||
"Vector sync state shared with browser_app for /user/page"
|
"Vector sync state shared with browser_app for /app"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1212,6 +1236,31 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def handle_nextcloud_webhook(request):
|
||||||
|
"""Test webhook endpoint to capture and log Nextcloud webhook payloads.
|
||||||
|
|
||||||
|
This is a temporary endpoint for testing webhook schemas and payloads.
|
||||||
|
It logs the full payload and returns 200 OK immediately.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
logger.info("=" * 80)
|
||||||
|
logger.info("🔔 Webhook received from Nextcloud:")
|
||||||
|
logger.info(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
logger.info("=" * 80)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "received", "timestamp": payload.get("time")},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to parse webhook payload: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_payload", "message": str(e)}, status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
|
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
|
||||||
routes = []
|
routes = []
|
||||||
|
|
||||||
@@ -1220,6 +1269,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
routes.append(Route("/health/ready", health_ready, methods=["GET"]))
|
routes.append(Route("/health/ready", health_ready, methods=["GET"]))
|
||||||
logger.info("Health check endpoints enabled: /health/live, /health/ready")
|
logger.info("Health check endpoints enabled: /health/live, /health/ready")
|
||||||
|
|
||||||
|
# Add test webhook endpoint (for development/testing)
|
||||||
|
routes.append(
|
||||||
|
Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"])
|
||||||
|
)
|
||||||
|
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
||||||
|
|
||||||
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
||||||
|
|
||||||
@@ -1355,17 +1410,37 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
revoke_session,
|
revoke_session,
|
||||||
user_info_html,
|
user_info_html,
|
||||||
user_info_json,
|
vector_sync_status_fragment,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||||
|
disable_webhook_preset,
|
||||||
|
enable_webhook_preset,
|
||||||
|
webhook_management_pane,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a separate Starlette app for browser routes that need session auth
|
# Create a separate Starlette app for browser routes that need session auth
|
||||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||||
browser_routes = [
|
browser_routes = [
|
||||||
Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json
|
Route("/", user_info_html, methods=["GET"]), # /app → webapp (HTML UI)
|
||||||
Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html
|
|
||||||
Route(
|
Route(
|
||||||
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
||||||
), # /user/revoke → revoke_session
|
), # /app/revoke → revoke_session
|
||||||
|
# Vector sync status fragment (htmx polling)
|
||||||
|
Route(
|
||||||
|
"/vector-sync/status",
|
||||||
|
vector_sync_status_fragment,
|
||||||
|
methods=["GET"],
|
||||||
|
), # /app/vector-sync/status
|
||||||
|
# Webhook management routes (admin-only)
|
||||||
|
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
|
||||||
|
Route(
|
||||||
|
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/webhooks/disable/{preset_id:str}",
|
||||||
|
disable_webhook_preset,
|
||||||
|
methods=["DELETE"],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
browser_app = Starlette(routes=browser_routes)
|
browser_app = Starlette(routes=browser_routes)
|
||||||
@@ -1374,9 +1449,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mount browser app at /user (so /user and /user/page work)
|
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||||
routes.append(Mount("/user", app=browser_app))
|
routes.append(
|
||||||
logger.info("User info routes with session auth: /user, /user/page")
|
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount browser app at /app (webapp and admin routes)
|
||||||
|
routes.append(Mount("/app", app=browser_app))
|
||||||
|
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||||
|
|
||||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||||
routes.append(Mount("/", app=mcp_app))
|
routes.append(Mount("/", app=mcp_app))
|
||||||
@@ -1497,249 +1577,3 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option(
|
|
||||||
"--host", "-h", default="127.0.0.1", show_default=True, help="Server host"
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--log-level",
|
|
||||||
"-l",
|
|
||||||
default="info",
|
|
||||||
show_default=True,
|
|
||||||
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
|
|
||||||
help="Logging level",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--transport",
|
|
||||||
"-t",
|
|
||||||
default="sse",
|
|
||||||
show_default=True,
|
|
||||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
|
||||||
help="MCP transport protocol",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--enable-app",
|
|
||||||
"-e",
|
|
||||||
multiple=True,
|
|
||||||
type=click.Choice(
|
|
||||||
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
|
|
||||||
),
|
|
||||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--oauth/--no-oauth",
|
|
||||||
default=None,
|
|
||||||
help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--oauth-client-id",
|
|
||||||
envvar="NEXTCLOUD_OIDC_CLIENT_ID",
|
|
||||||
help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--oauth-client-secret",
|
|
||||||
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
|
|
||||||
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--mcp-server-url",
|
|
||||||
envvar="NEXTCLOUD_MCP_SERVER_URL",
|
|
||||||
default="http://localhost:8000",
|
|
||||||
show_default=True,
|
|
||||||
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--nextcloud-host",
|
|
||||||
envvar="NEXTCLOUD_HOST",
|
|
||||||
help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--nextcloud-username",
|
|
||||||
envvar="NEXTCLOUD_USERNAME",
|
|
||||||
help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--nextcloud-password",
|
|
||||||
envvar="NEXTCLOUD_PASSWORD",
|
|
||||||
help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--oauth-scopes",
|
|
||||||
envvar="NEXTCLOUD_OIDC_SCOPES",
|
|
||||||
default="openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
|
|
||||||
show_default=True,
|
|
||||||
help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--oauth-token-type",
|
|
||||||
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
|
|
||||||
default="bearer",
|
|
||||||
show_default=True,
|
|
||||||
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
|
|
||||||
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--public-issuer-url",
|
|
||||||
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
|
|
||||||
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
|
|
||||||
)
|
|
||||||
def run(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
log_level: str,
|
|
||||||
transport: str,
|
|
||||||
enable_app: tuple[str, ...],
|
|
||||||
oauth: bool | None,
|
|
||||||
oauth_client_id: str | None,
|
|
||||||
oauth_client_secret: str | None,
|
|
||||||
mcp_server_url: str,
|
|
||||||
nextcloud_host: str | None,
|
|
||||||
nextcloud_username: str | None,
|
|
||||||
nextcloud_password: str | None,
|
|
||||||
oauth_scopes: str,
|
|
||||||
oauth_token_type: str,
|
|
||||||
public_issuer_url: str | None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Run the Nextcloud MCP server.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Authentication Modes:
|
|
||||||
- BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD
|
|
||||||
- OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled)
|
|
||||||
|
|
||||||
\b
|
|
||||||
Examples:
|
|
||||||
# BasicAuth mode with CLI options
|
|
||||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
|
|
||||||
--nextcloud-username=admin --nextcloud-password=secret
|
|
||||||
|
|
||||||
# BasicAuth mode with env vars (recommended for credentials)
|
|
||||||
$ export NEXTCLOUD_HOST=https://cloud.example.com
|
|
||||||
$ export NEXTCLOUD_USERNAME=admin
|
|
||||||
$ export NEXTCLOUD_PASSWORD=secret
|
|
||||||
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
|
||||||
|
|
||||||
# OAuth mode with auto-registration
|
|
||||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
|
|
||||||
|
|
||||||
# OAuth mode with pre-configured client
|
|
||||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
|
||||||
--oauth-client-id=xxx --oauth-client-secret=yyy
|
|
||||||
|
|
||||||
# OAuth mode with custom scopes and JWT tokens
|
|
||||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
|
||||||
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
|
|
||||||
|
|
||||||
# OAuth with public issuer URL (for Docker/proxy setups)
|
|
||||||
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
|
||||||
--public-issuer-url=http://localhost:8080
|
|
||||||
"""
|
|
||||||
# Set env vars from CLI options if provided
|
|
||||||
if nextcloud_host:
|
|
||||||
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
|
|
||||||
if nextcloud_username:
|
|
||||||
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
|
|
||||||
if nextcloud_password:
|
|
||||||
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
|
|
||||||
if oauth_client_id:
|
|
||||||
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
|
|
||||||
if oauth_client_secret:
|
|
||||||
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
|
|
||||||
if oauth_scopes:
|
|
||||||
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
|
|
||||||
if oauth_token_type:
|
|
||||||
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
|
|
||||||
if mcp_server_url:
|
|
||||||
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
|
|
||||||
if public_issuer_url:
|
|
||||||
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
|
|
||||||
|
|
||||||
# Force OAuth mode if explicitly requested
|
|
||||||
if oauth is True:
|
|
||||||
# Clear username/password to force OAuth mode
|
|
||||||
if "NEXTCLOUD_USERNAME" in os.environ:
|
|
||||||
click.echo(
|
|
||||||
"Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True
|
|
||||||
)
|
|
||||||
del os.environ["NEXTCLOUD_USERNAME"]
|
|
||||||
if "NEXTCLOUD_PASSWORD" in os.environ:
|
|
||||||
click.echo(
|
|
||||||
"Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True
|
|
||||||
)
|
|
||||||
del os.environ["NEXTCLOUD_PASSWORD"]
|
|
||||||
|
|
||||||
# Validate OAuth configuration
|
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
|
||||||
if not nextcloud_host:
|
|
||||||
raise click.ClickException(
|
|
||||||
"OAuth mode requires NEXTCLOUD_HOST environment variable to be set"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if we have client credentials OR if dynamic registration is possible
|
|
||||||
has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv(
|
|
||||||
"NEXTCLOUD_OIDC_CLIENT_SECRET"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not has_client_creds:
|
|
||||||
# No client credentials - will attempt dynamic registration
|
|
||||||
# Show helpful message before server starts
|
|
||||||
click.echo("", err=True)
|
|
||||||
click.echo("OAuth Configuration:", err=True)
|
|
||||||
click.echo(" Mode: Dynamic Client Registration", err=True)
|
|
||||||
click.echo(" Host: " + nextcloud_host, err=True)
|
|
||||||
click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True)
|
|
||||||
click.echo("", err=True)
|
|
||||||
click.echo(
|
|
||||||
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
|
|
||||||
)
|
|
||||||
click.echo(" in your Nextcloud OIDC app settings.", err=True)
|
|
||||||
click.echo("", err=True)
|
|
||||||
else:
|
|
||||||
click.echo("", err=True)
|
|
||||||
click.echo("OAuth Configuration:", err=True)
|
|
||||||
click.echo(" Mode: Pre-configured Client", err=True)
|
|
||||||
click.echo(" Host: " + nextcloud_host, err=True)
|
|
||||||
click.echo(
|
|
||||||
" Client ID: "
|
|
||||||
+ os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16]
|
|
||||||
+ "...",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
click.echo("", err=True)
|
|
||||||
|
|
||||||
elif oauth is False:
|
|
||||||
# Force BasicAuth mode - verify credentials exist
|
|
||||||
if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"):
|
|
||||||
raise click.ClickException(
|
|
||||||
"--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set"
|
|
||||||
)
|
|
||||||
|
|
||||||
enabled_apps = list(enable_app) if enable_app else None
|
|
||||||
|
|
||||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
|
||||||
|
|
||||||
# Get observability settings and create uvicorn logging config
|
|
||||||
settings = get_settings()
|
|
||||||
uvicorn_log_config = get_uvicorn_logging_config(
|
|
||||||
log_format=settings.log_format,
|
|
||||||
log_level=settings.log_level,
|
|
||||||
include_trace_context=settings.log_include_trace_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
uvicorn.run(
|
|
||||||
app=app,
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
log_level=log_level,
|
|
||||||
log_config=uvicorn_log_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run()
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Browser-based OAuth login routes for admin UI.
|
"""Browser-based OAuth login routes for admin UI.
|
||||||
|
|
||||||
Separate from MCP OAuth flow - these routes establish browser sessions
|
Separate from MCP OAuth flow - these routes establish browser sessions
|
||||||
for accessing admin UI endpoints like /user/page.
|
for accessing admin UI endpoints like /app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -38,8 +38,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
"""
|
"""
|
||||||
oauth_ctx = request.app.state.oauth_context
|
oauth_ctx = request.app.state.oauth_context
|
||||||
if not oauth_ctx:
|
if not oauth_ctx:
|
||||||
# BasicAuth mode - no login needed, redirect to user page
|
# BasicAuth mode - no login needed, redirect to app
|
||||||
return RedirectResponse("/user/page", status_code=302)
|
return RedirectResponse("/app", status_code=302)
|
||||||
|
|
||||||
storage = oauth_ctx["storage"]
|
storage = oauth_ctx["storage"]
|
||||||
oauth_client = oauth_ctx["oauth_client"]
|
oauth_client = oauth_ctx["oauth_client"]
|
||||||
@@ -71,7 +71,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
await storage.store_oauth_session(
|
await storage.store_oauth_session(
|
||||||
session_id=state, # Use state as session ID
|
session_id=state, # Use state as session ID
|
||||||
client_id="browser-ui",
|
client_id="browser-ui",
|
||||||
client_redirect_uri="/user/page",
|
client_redirect_uri="/app",
|
||||||
state=state,
|
state=state,
|
||||||
code_challenge=code_challenge,
|
code_challenge=code_challenge,
|
||||||
code_challenge_method="S256",
|
code_challenge_method="S256",
|
||||||
@@ -383,7 +383,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
# Continue anyway - profile cache is optional for browser UI
|
# Continue anyway - profile cache is optional for browser UI
|
||||||
|
|
||||||
# Create response and set session cookie
|
# Create response and set session cookie
|
||||||
response = RedirectResponse("/user/page", status_code=302)
|
response = RedirectResponse("/app", status_code=302)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="mcp_session",
|
key="mcp_session",
|
||||||
value=user_id,
|
value=user_id,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
import anyio
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from starlette.requests import Request
|
|||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Permission checking utilities for Nextcloud admin operations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client.users import UsersClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_nextcloud_admin(request: Request, http_client: AsyncClient) -> bool:
|
||||||
|
"""Check if the authenticated user is a Nextcloud administrator.
|
||||||
|
|
||||||
|
This function extracts the username from the session/request context
|
||||||
|
and checks if the user is a member of the "admin" group in Nextcloud.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object with authenticated user
|
||||||
|
http_client: Authenticated HTTP client for Nextcloud API calls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user is admin, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
if await is_nextcloud_admin(request, http_client):
|
||||||
|
# Show admin-only features
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract username from authenticated session
|
||||||
|
username = request.user.display_name
|
||||||
|
if not username:
|
||||||
|
logger.warning("No username found in authenticated session")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Query Nextcloud for user's group memberships
|
||||||
|
users_client = UsersClient(http_client, username)
|
||||||
|
user_groups = await users_client.get_user_groups(username)
|
||||||
|
|
||||||
|
# Check if user is in the admin group
|
||||||
|
is_admin = "admin" in user_groups
|
||||||
|
logger.debug(
|
||||||
|
f"Admin check for user '{username}': {is_admin} (groups: {user_groups})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return is_admin
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking admin permissions: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
@@ -13,7 +13,7 @@ from mcp.server.fastmcp import Context
|
|||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
+206
-42
@@ -1,23 +1,28 @@
|
|||||||
"""
|
"""
|
||||||
Refresh Token Storage for ADR-002 Tier 1: Offline Access
|
Persistent Storage for MCP Server State
|
||||||
|
|
||||||
Manages two separate concerns for OAuth authentication:
|
This module provides SQLite-based storage for multiple concerns across both
|
||||||
|
BasicAuth and OAuth authentication modes:
|
||||||
|
|
||||||
1. **Refresh Tokens** (for background jobs ONLY)
|
1. **Refresh Tokens** (OAuth mode only, for background jobs)
|
||||||
- Securely stores encrypted refresh tokens for offline access
|
- Securely stores encrypted refresh tokens for offline access
|
||||||
- Used ONLY by background jobs to obtain access tokens
|
- Used ONLY by background jobs to obtain access tokens
|
||||||
- NEVER used within MCP client sessions or browser sessions
|
- NEVER used within MCP client sessions or browser sessions
|
||||||
|
|
||||||
2. **User Profile Cache** (for browser UI display ONLY)
|
2. **User Profile Cache** (OAuth mode only, for browser UI display)
|
||||||
- Caches IdP user profile data for browser-based admin UI
|
- Caches IdP user profile data for browser-based admin UI
|
||||||
- Queried ONCE at login, displayed from cache thereafter
|
- Queried ONCE at login, displayed from cache thereafter
|
||||||
- NOT used for authorization decisions or background jobs
|
- NOT used for authorization decisions or background jobs
|
||||||
|
|
||||||
IMPORTANT: These are separate concerns. Browser sessions read profile cache for
|
3. **Webhook Registration Tracking** (both modes, for webhook management)
|
||||||
display purposes. Background jobs use refresh tokens for API access. Never mix
|
- Tracks registered webhook IDs mapped to presets
|
||||||
the two.
|
- Enables persistent webhook state across restarts
|
||||||
|
- Avoids redundant Nextcloud API calls for webhook status
|
||||||
|
|
||||||
Tokens are encrypted at rest using Fernet symmetric encryption.
|
IMPORTANT: The database is initialized in both BasicAuth and OAuth modes.
|
||||||
|
Token storage requires TOKEN_ENCRYPTION_KEY, but webhook tracking does not.
|
||||||
|
|
||||||
|
Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric encryption.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -34,25 +39,34 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class RefreshTokenStorage:
|
class RefreshTokenStorage:
|
||||||
"""Securely store and manage user refresh tokens and profile cache.
|
"""Persistent storage for MCP server state (tokens, webhooks, and future features).
|
||||||
|
|
||||||
This class manages two separate concerns:
|
This class manages multiple concerns across both BasicAuth and OAuth modes:
|
||||||
- Refresh tokens: Encrypted storage for background job access (write-only by OAuth, read-only by background jobs)
|
|
||||||
- User profiles: Plain JSON cache for browser UI display (written at login, read by UI)
|
|
||||||
|
|
||||||
These concerns are architecturally separate and should never be mixed.
|
**OAuth-specific concerns**:
|
||||||
|
- Refresh tokens: Encrypted storage for background job access (requires encryption key)
|
||||||
|
- User profiles: Plain JSON cache for browser UI display
|
||||||
|
- OAuth client credentials: Encrypted client secrets from DCR
|
||||||
|
- OAuth sessions: Temporary session state for progressive consent flow
|
||||||
|
|
||||||
|
**Both modes**:
|
||||||
|
- Webhook registration: Track registered webhooks mapped to presets
|
||||||
|
- Schema versioning: Handle database migrations automatically
|
||||||
|
|
||||||
|
Token-related operations require TOKEN_ENCRYPTION_KEY, but webhook operations do not.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db_path: str, encryption_key: bytes):
|
def __init__(self, db_path: str, encryption_key: bytes | None = None):
|
||||||
"""
|
"""
|
||||||
Initialize refresh token storage.
|
Initialize persistent storage.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: Path to SQLite database file
|
db_path: Path to SQLite database file
|
||||||
encryption_key: Fernet encryption key (32 bytes, base64-encoded)
|
encryption_key: Optional Fernet encryption key (32 bytes, base64-encoded).
|
||||||
|
Required for token storage operations, not required for webhook tracking.
|
||||||
"""
|
"""
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.cipher = Fernet(encryption_key)
|
self.cipher = Fernet(encryption_key) if encryption_key else None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -62,41 +76,42 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
|
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
|
||||||
TOKEN_ENCRYPTION_KEY: Base64-encoded Fernet key
|
TOKEN_ENCRYPTION_KEY: Optional base64-encoded Fernet key (required for token storage)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RefreshTokenStorage instance
|
RefreshTokenStorage instance
|
||||||
|
|
||||||
Raises:
|
Note:
|
||||||
ValueError: If TOKEN_ENCRYPTION_KEY is not set
|
If TOKEN_ENCRYPTION_KEY is not set, token storage operations will fail,
|
||||||
|
but webhook tracking will still work.
|
||||||
"""
|
"""
|
||||||
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
|
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
|
||||||
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
|
|
||||||
if not encryption_key_b64:
|
encryption_key = None
|
||||||
raise ValueError(
|
if encryption_key_b64:
|
||||||
"TOKEN_ENCRYPTION_KEY environment variable is required. "
|
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
|
||||||
"Generate one with: python -c 'from cryptography.fernet import Fernet; "
|
# The key from Fernet.generate_key() is already base64url-encoded
|
||||||
"print(Fernet.generate_key().decode())'"
|
try:
|
||||||
|
# Convert string to bytes if needed
|
||||||
|
if isinstance(encryption_key_b64, str):
|
||||||
|
encryption_key = encryption_key_b64.encode()
|
||||||
|
else:
|
||||||
|
encryption_key = encryption_key_b64
|
||||||
|
|
||||||
|
# Validate the key by trying to create a Fernet instance
|
||||||
|
Fernet(encryption_key)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
|
||||||
|
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
|
||||||
|
) from e
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"TOKEN_ENCRYPTION_KEY not set - token storage operations will be unavailable, "
|
||||||
|
"but webhook tracking will still work"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
|
|
||||||
# The key from Fernet.generate_key() is already base64url-encoded
|
|
||||||
try:
|
|
||||||
# Convert string to bytes if needed
|
|
||||||
if isinstance(encryption_key_b64, str):
|
|
||||||
encryption_key = encryption_key_b64.encode()
|
|
||||||
else:
|
|
||||||
encryption_key = encryption_key_b64
|
|
||||||
|
|
||||||
# Validate the key by trying to create a Fernet instance
|
|
||||||
Fernet(encryption_key)
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
|
|
||||||
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return cls(db_path=db_path, encryption_key=encryption_key)
|
return cls(db_path=db_path, encryption_key=encryption_key)
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@@ -204,6 +219,38 @@ class RefreshTokenStorage:
|
|||||||
"ON oauth_sessions(mcp_authorization_code)"
|
"ON oauth_sessions(mcp_authorization_code)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Schema version tracking
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registered webhooks tracking (both BasicAuth and OAuth modes)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS registered_webhooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
webhook_id INTEGER NOT NULL UNIQUE,
|
||||||
|
preset_id TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for efficient webhook queries
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_webhooks_preset "
|
||||||
|
"ON registered_webhooks(preset_id)"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_webhooks_created "
|
||||||
|
"ON registered_webhooks(created_at)"
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Set restrictive permissions after creation
|
# Set restrictive permissions after creation
|
||||||
@@ -1104,6 +1151,123 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Webhook Registration Tracking (both BasicAuth and OAuth modes)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def store_webhook(self, webhook_id: int, preset_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Store registered webhook ID for tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhook_id: Nextcloud webhook ID
|
||||||
|
preset_id: Preset identifier (e.g., "notes_sync", "calendar_sync")
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO registered_webhooks (webhook_id, preset_id, created_at) VALUES (?, ?, ?)",
|
||||||
|
(webhook_id, preset_id, time.time()),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.debug(f"Stored webhook {webhook_id} for preset '{preset_id}'")
|
||||||
|
|
||||||
|
async def get_webhooks_by_preset(self, preset_id: str) -> list[int]:
|
||||||
|
"""
|
||||||
|
Get all webhook IDs registered for a preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset_id: Preset identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of webhook IDs
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT webhook_id FROM registered_webhooks WHERE preset_id = ?",
|
||||||
|
(preset_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
async def delete_webhook(self, webhook_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Remove webhook from tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhook_id: Nextcloud webhook ID to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if webhook was deleted, False if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM registered_webhooks WHERE webhook_id = ?", (webhook_id,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount > 0
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.debug(f"Deleted webhook {webhook_id} from tracking")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
async def list_all_webhooks(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
List all tracked webhooks with metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of webhook dictionaries with keys: webhook_id, preset_id, created_at
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT webhook_id, preset_id, created_at FROM registered_webhooks ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"webhook_id": row[0], "preset_id": row[1], "created_at": row[2]}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def clear_preset_webhooks(self, preset_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Delete all webhooks for a preset (bulk operation).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset_id: Preset identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of webhooks deleted
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM registered_webhooks WHERE preset_id = ?", (preset_id,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount
|
||||||
|
|
||||||
|
if deleted > 0:
|
||||||
|
logger.debug(f"Cleared {deleted} webhook(s) for preset '{preset_id}'")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
async def generate_encryption_key() -> str:
|
async def generate_encryption_key() -> str:
|
||||||
"""
|
"""
|
||||||
@@ -23,7 +23,7 @@ import httpx
|
|||||||
import jwt
|
import jwt
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import httpx
|
|||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from .refresh_token_storage import RefreshTokenStorage
|
from .storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,57 @@ from starlette.responses import HTMLResponse, JSONResponse
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.AsyncClient:
|
||||||
|
"""Get an authenticated HTTP client for user info page operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated httpx.AsyncClient
|
||||||
|
"""
|
||||||
|
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||||
|
|
||||||
|
# BasicAuth mode - use credentials from environment
|
||||||
|
if not oauth_ctx:
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
|
||||||
|
if not all([nextcloud_host, username, password]):
|
||||||
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
|
assert nextcloud_host is not None # Type narrowing for type checker
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
auth=(username, password),
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth mode - get token from session
|
||||||
|
storage = oauth_ctx.get("storage")
|
||||||
|
session_id = request.cookies.get("mcp_session")
|
||||||
|
|
||||||
|
if not storage or not session_id:
|
||||||
|
raise RuntimeError("Session not found")
|
||||||
|
|
||||||
|
token_data = await storage.get_refresh_token(session_id)
|
||||||
|
if not token_data or "access_token" not in token_data:
|
||||||
|
raise RuntimeError("No access token found in session")
|
||||||
|
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise RuntimeError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||||
"""Get vector sync processing status.
|
"""Get vector sync processing status.
|
||||||
|
|
||||||
@@ -88,6 +139,71 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@requires("authenticated", redirect="oauth_login")
|
||||||
|
async def vector_sync_status_fragment(request: Request) -> HTMLResponse:
|
||||||
|
"""Vector sync status fragment endpoint - returns HTML fragment with current status.
|
||||||
|
|
||||||
|
This endpoint is polled by htmx to provide real-time updates of vector sync processing
|
||||||
|
status without requiring a full page refresh.
|
||||||
|
|
||||||
|
Requires authentication via session cookie (redirects to oauth_login route if not authenticated).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML response with vector sync status table fragment
|
||||||
|
"""
|
||||||
|
processing_status = await _get_processing_status(request)
|
||||||
|
|
||||||
|
# If vector sync is disabled or unavailable, return empty fragment
|
||||||
|
if not processing_status:
|
||||||
|
return HTMLResponse(
|
||||||
|
"""
|
||||||
|
<div id="vector-sync-status" hx-get="/app/vector-sync/status" hx-trigger="every 10s" hx-swap="innerHTML">
|
||||||
|
<p style="color: #999;">Vector sync not available</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
indexed_count = processing_status["indexed_count"]
|
||||||
|
pending_count = processing_status["pending_count"]
|
||||||
|
status = processing_status["status"]
|
||||||
|
|
||||||
|
# Format numbers with commas for readability
|
||||||
|
indexed_count_str = f"{indexed_count:,}"
|
||||||
|
pending_count_str = f"{pending_count:,}"
|
||||||
|
|
||||||
|
# Status badge color and text
|
||||||
|
if status == "syncing":
|
||||||
|
status_badge = (
|
||||||
|
'<span style="color: #ff9800; font-weight: bold;">⟳ Syncing</span>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status_badge = '<span style="color: #4caf50; font-weight: bold;">✓ Idle</span>'
|
||||||
|
|
||||||
|
# Return inner content only (container div is in initial page render)
|
||||||
|
html = f"""
|
||||||
|
<h2>Vector Sync Status</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Indexed Documents</strong></td>
|
||||||
|
<td>{indexed_count_str}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Pending Documents</strong></td>
|
||||||
|
<td>{pending_count_str}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Status</strong></td>
|
||||||
|
<td>{status_badge}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||||
"""Get the correct userinfo endpoint based on OAuth mode.
|
"""Get the correct userinfo endpoint based on OAuth mode.
|
||||||
|
|
||||||
@@ -296,6 +412,19 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
# Get vector sync processing status
|
# Get vector sync processing status
|
||||||
processing_status = await _get_processing_status(request)
|
processing_status = await _get_processing_status(request)
|
||||||
|
|
||||||
|
# Check if user is admin (for Webhooks tab)
|
||||||
|
is_admin = False
|
||||||
|
try:
|
||||||
|
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||||
|
|
||||||
|
# Get authenticated HTTP client
|
||||||
|
http_client = await _get_authenticated_client_for_userinfo(request)
|
||||||
|
is_admin = await is_nextcloud_admin(request, http_client)
|
||||||
|
await http_client.aclose()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to check admin status: {e}")
|
||||||
|
# Default to not admin if check fails
|
||||||
|
|
||||||
# Check for error
|
# Check for error
|
||||||
if "error" in user_context and user_context["error"] != "":
|
if "error" in user_context and user_context["error"] != "":
|
||||||
# Get login URL dynamically
|
# Get login URL dynamically
|
||||||
@@ -443,43 +572,15 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Build vector sync status HTML
|
# Build vector sync status HTML (with htmx auto-refresh)
|
||||||
vector_status_html = ""
|
vector_status_html = ""
|
||||||
if processing_status:
|
if processing_status:
|
||||||
indexed_count = processing_status["indexed_count"]
|
# Use htmx to load and auto-refresh the status fragment
|
||||||
pending_count = processing_status["pending_count"]
|
# Container div stays stable, only inner content updates every 10s
|
||||||
status = processing_status["status"]
|
vector_status_html = """
|
||||||
|
<div id="vector-sync-status" hx-get="/app/vector-sync/status" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||||
# Format numbers with commas for readability
|
<p style="color: #999;">Loading vector sync status...</p>
|
||||||
indexed_count_str = f"{indexed_count:,}"
|
</div>
|
||||||
pending_count_str = f"{pending_count:,}"
|
|
||||||
|
|
||||||
# Status badge color and text
|
|
||||||
if status == "syncing":
|
|
||||||
status_badge = (
|
|
||||||
'<span style="color: #ff9800; font-weight: bold;">⟳ Syncing</span>'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
status_badge = (
|
|
||||||
'<span style="color: #4caf50; font-weight: bold;">✓ Idle</span>'
|
|
||||||
)
|
|
||||||
|
|
||||||
vector_status_html = f"""
|
|
||||||
<h2>Vector Sync Status</h2>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Indexed Documents</strong></td>
|
|
||||||
<td>{indexed_count_str}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Pending Documents</strong></td>
|
|
||||||
<td>{pending_count_str}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Status</strong></td>
|
|
||||||
<td>{status_badge}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Build IdP profile HTML
|
# Build IdP profile HTML
|
||||||
@@ -506,17 +607,61 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
<div class="warning">{user_context["idp_profile_error"]}</div>
|
<div class="warning">{user_context["idp_profile_error"]}</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Build user info tab content
|
||||||
|
user_info_tab_html = f"""
|
||||||
|
<h2>Authentication</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Username</strong></td>
|
||||||
|
<td>{username}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Authentication Mode</strong></td>
|
||||||
|
<td><span class="badge badge-{auth_mode}">{auth_mode}</span></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{host_info_html}
|
||||||
|
{session_info_html}
|
||||||
|
{idp_profile_html}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Determine which tabs to show
|
||||||
|
show_vector_sync_tab = processing_status is not None
|
||||||
|
show_webhooks_tab = is_admin
|
||||||
|
|
||||||
|
# Build vector sync tab content (only if enabled)
|
||||||
|
vector_sync_tab_html = ""
|
||||||
|
if show_vector_sync_tab:
|
||||||
|
vector_sync_tab_html = vector_status_html
|
||||||
|
|
||||||
|
# Build webhooks tab content (only if admin)
|
||||||
|
webhooks_tab_html = ""
|
||||||
|
if show_webhooks_tab:
|
||||||
|
webhooks_tab_html = """
|
||||||
|
<div hx-get="/app/webhooks" hx-trigger="load" hx-swap="outerHTML">
|
||||||
|
<p style="color: #999;">Loading webhook management...</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>User Info - Nextcloud MCP Server</title>
|
<title>Nextcloud MCP Server</title>
|
||||||
|
|
||||||
|
<!-- htmx for dynamic loading -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Alpine.js for tab state management -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {{
|
body {{
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
max-width: 800px;
|
max-width: 900px;
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
@@ -526,6 +671,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
}}
|
}}
|
||||||
h1 {{
|
h1 {{
|
||||||
color: #0082c9;
|
color: #0082c9;
|
||||||
@@ -535,10 +681,51 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
}}
|
}}
|
||||||
h2 {{
|
h2 {{
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-top: 30px;
|
margin-top: 20px;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* Tab navigation */
|
||||||
|
.tabs {{
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin: 20px 0 0 0;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
}}
|
||||||
|
.tab {{
|
||||||
|
padding: 12px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}}
|
||||||
|
.tab:hover {{
|
||||||
|
color: #0082c9;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}}
|
||||||
|
.tab.active {{
|
||||||
|
color: #0082c9;
|
||||||
|
border-bottom-color: #0082c9;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Tab content - use grid to overlay panes */
|
||||||
|
.tab-content {{
|
||||||
|
padding: 20px 0;
|
||||||
|
display: grid;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Tab panes - all occupy the same grid cell to overlay */
|
||||||
|
.tab-pane {{
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
table {{
|
table {{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -558,6 +745,8 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
.badge {{
|
.badge {{
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -574,6 +763,8 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
background-color: #2196f3;
|
background-color: #2196f3;
|
||||||
color: white;
|
color: white;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
.warning {{
|
.warning {{
|
||||||
background-color: #fff3cd;
|
background-color: #fff3cd;
|
||||||
border-left: 4px solid #ffc107;
|
border-left: 4px solid #ffc107;
|
||||||
@@ -581,11 +772,15 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
color: #856404;
|
color: #856404;
|
||||||
}}
|
}}
|
||||||
.logout {{
|
.info-message {{
|
||||||
margin-top: 30px;
|
background-color: #e3f2fd;
|
||||||
padding-top: 20px;
|
border-left: 4px solid #2196f3;
|
||||||
border-top: 1px solid #e0e0e0;
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
color: #1565c0;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
.button {{
|
.button {{
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
@@ -594,34 +789,113 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
}}
|
}}
|
||||||
.button:hover {{
|
.button:hover {{
|
||||||
background-color: #b71c1c;
|
background-color: #b71c1c;
|
||||||
}}
|
}}
|
||||||
|
.button-primary {{
|
||||||
|
background-color: #0082c9;
|
||||||
|
}}
|
||||||
|
.button-primary:hover {{
|
||||||
|
background-color: #006ba3;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Logout section */
|
||||||
|
.logout {{
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Smooth htmx content swaps */
|
||||||
|
.htmx-swapping {{
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-out;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Smooth htmx content settling */
|
||||||
|
.htmx-settling {{
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 200ms ease-in;
|
||||||
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container" x-data="{{ activeTab: 'user-info' }}">
|
||||||
<h1>Nextcloud MCP Server - User Info</h1>
|
<h1>Nextcloud MCP Server</h1>
|
||||||
|
|
||||||
<h2>Authentication</h2>
|
<!-- Tab Navigation -->
|
||||||
<table>
|
<div class="tabs">
|
||||||
<tr>
|
<button
|
||||||
<td><strong>Username</strong></td>
|
class="tab"
|
||||||
<td>{username}</td>
|
:class="activeTab === 'user-info' ? 'active' : ''"
|
||||||
</tr>
|
@click="activeTab = 'user-info'">
|
||||||
<tr>
|
User Info
|
||||||
<td><strong>Authentication Mode</strong></td>
|
</button>
|
||||||
<td><span class="badge badge-{auth_mode}">{auth_mode}</span></td>
|
{
|
||||||
</tr>
|
""
|
||||||
</table>
|
if not show_vector_sync_tab
|
||||||
|
else '''
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
:class="activeTab === 'vector-sync' ? 'active' : ''"
|
||||||
|
@click="activeTab = 'vector-sync'">
|
||||||
|
Vector Sync
|
||||||
|
</button>
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
{
|
||||||
|
""
|
||||||
|
if not show_webhooks_tab
|
||||||
|
else '''
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
:class="activeTab === 'webhooks' ? 'active' : ''"
|
||||||
|
@click="activeTab = 'webhooks'">
|
||||||
|
Webhooks
|
||||||
|
</button>
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
{host_info_html}
|
<!-- Tab Content -->
|
||||||
{session_info_html}
|
<div class="tab-content">
|
||||||
{vector_status_html}
|
<!-- User Info Tab -->
|
||||||
{idp_profile_html}
|
<div class="tab-pane" x-show="activeTab === 'user-info'" x-transition.opacity.duration.150ms>
|
||||||
|
{user_info_tab_html}
|
||||||
|
</div>
|
||||||
|
|
||||||
{f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>' if auth_mode == "oauth" else ""}
|
{
|
||||||
|
""
|
||||||
|
if not show_vector_sync_tab
|
||||||
|
else f'''
|
||||||
|
<!-- Vector Sync Tab -->
|
||||||
|
<div class="tab-pane" x-show="activeTab === 'vector-sync'" x-transition.opacity.duration.150ms>
|
||||||
|
{vector_sync_tab_html}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
""
|
||||||
|
if not show_webhooks_tab
|
||||||
|
else f'''
|
||||||
|
<!-- Webhooks Tab (admin-only, loaded dynamically) -->
|
||||||
|
<div class="tab-pane" x-show="activeTab === 'webhooks'" x-transition.opacity.duration.150ms>
|
||||||
|
{webhooks_tab_html}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>'
|
||||||
|
if auth_mode == "oauth"
|
||||||
|
else ""
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
"""Webhook management routes for admin UI.
|
||||||
|
|
||||||
|
Provides browser-based endpoints for admin users to manage webhook configurations
|
||||||
|
using preset templates. Only accessible to Nextcloud administrators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from starlette.authentication import requires
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||||
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
from nextcloud_mcp_server.server.webhook_presets import (
|
||||||
|
WEBHOOK_PRESETS,
|
||||||
|
filter_presets_by_installed_apps,
|
||||||
|
get_preset,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_storage(request: Request):
|
||||||
|
"""Get storage instance from app state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RefreshTokenStorage instance or None
|
||||||
|
"""
|
||||||
|
# Try browser_app state first (for /app routes)
|
||||||
|
storage = getattr(request.app.state, "storage", None)
|
||||||
|
|
||||||
|
# Try oauth_context if in OAuth mode
|
||||||
|
if not storage:
|
||||||
|
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||||
|
if oauth_ctx:
|
||||||
|
storage = oauth_ctx.get("storage")
|
||||||
|
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_installed_apps(http_client: httpx.AsyncClient) -> list[str]:
|
||||||
|
"""Get list of installed and enabled apps from Nextcloud capabilities.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
http_client: Authenticated HTTP client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of installed app names (e.g., ["notes", "calendar", "forms"])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await http_client.get(
|
||||||
|
"/ocs/v2.php/cloud/capabilities",
|
||||||
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Extract app names from capabilities
|
||||||
|
capabilities = data.get("ocs", {}).get("data", {}).get("capabilities", {})
|
||||||
|
# Filter out core NC capabilities (not apps)
|
||||||
|
core_keys = {"version", "core"}
|
||||||
|
app_keys = set(capabilities.keys()) - core_keys
|
||||||
|
return sorted(app_keys)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get installed apps from capabilities: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_webhook_uri() -> str:
|
||||||
|
"""Get the webhook endpoint URI for this MCP server.
|
||||||
|
|
||||||
|
This function determines the correct webhook URL based on the environment:
|
||||||
|
1. Uses WEBHOOK_INTERNAL_URL if explicitly set (highest priority)
|
||||||
|
2. Detects Docker environment and uses internal service name
|
||||||
|
3. Falls back to NEXTCLOUD_MCP_SERVER_URL
|
||||||
|
|
||||||
|
In Docker environments, Nextcloud needs to reach the MCP service using
|
||||||
|
the internal Docker network hostname (e.g., http://mcp:8000), not localhost.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full webhook endpoint URL accessible from Nextcloud
|
||||||
|
"""
|
||||||
|
# Explicit override (highest priority)
|
||||||
|
webhook_url = os.getenv("WEBHOOK_INTERNAL_URL")
|
||||||
|
if webhook_url:
|
||||||
|
return f"{webhook_url}/webhooks/nextcloud"
|
||||||
|
|
||||||
|
# Detect Docker environment
|
||||||
|
# Check for common Docker indicators
|
||||||
|
is_docker = (
|
||||||
|
os.path.exists("/.dockerenv") # Docker container marker file
|
||||||
|
or os.path.exists("/run/.containerenv") # Podman marker
|
||||||
|
or os.getenv("DOCKER_CONTAINER") == "true" # Explicit flag
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_docker:
|
||||||
|
# In Docker, use internal service name from NEXTCLOUD_MCP_SERVICE_NAME
|
||||||
|
# or default to 'mcp' (docker-compose service name)
|
||||||
|
service_name = os.getenv("NEXTCLOUD_MCP_SERVICE_NAME", "mcp")
|
||||||
|
port = os.getenv("NEXTCLOUD_MCP_PORT", "8000")
|
||||||
|
logger.debug(
|
||||||
|
f"Docker environment detected, using internal URL: http://{service_name}:{port}"
|
||||||
|
)
|
||||||
|
return f"http://{service_name}:{port}/webhooks/nextcloud"
|
||||||
|
|
||||||
|
# Fallback to configured server URL (for non-Docker deployments)
|
||||||
|
server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||||
|
return f"{server_url}/webhooks/nextcloud"
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||||
|
"""Get an authenticated HTTP client for Nextcloud API calls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated httpx.AsyncClient
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If unable to create authenticated client
|
||||||
|
"""
|
||||||
|
# Get OAuth context from app state
|
||||||
|
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||||
|
|
||||||
|
# BasicAuth mode - use credentials from environment
|
||||||
|
if not oauth_ctx:
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
|
||||||
|
if not all([nextcloud_host, username, password]):
|
||||||
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
|
assert nextcloud_host is not None # Type narrowing for type checker
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
auth=(username, password),
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth mode - get token from session
|
||||||
|
storage = oauth_ctx.get("storage")
|
||||||
|
session_id = request.cookies.get("mcp_session")
|
||||||
|
|
||||||
|
if not storage or not session_id:
|
||||||
|
raise RuntimeError("Session not found")
|
||||||
|
|
||||||
|
token_data = await storage.get_refresh_token(session_id)
|
||||||
|
if not token_data or "access_token" not in token_data:
|
||||||
|
raise RuntimeError("No access token found in session")
|
||||||
|
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise RuntimeError("Nextcloud host not configured")
|
||||||
|
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_enabled_presets(
|
||||||
|
webhooks_client: WebhooksClient,
|
||||||
|
storage=None,
|
||||||
|
) -> dict[str, list[int]]:
|
||||||
|
"""Get currently enabled webhook presets.
|
||||||
|
|
||||||
|
Reads from database first for better performance. Falls back to API if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhooks_client: Webhooks API client
|
||||||
|
storage: Optional RefreshTokenStorage instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping preset_id to list of webhook IDs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Try database first (faster, works offline)
|
||||||
|
if storage:
|
||||||
|
all_webhooks = await storage.list_all_webhooks()
|
||||||
|
enabled_presets: dict[str, list[int]] = {}
|
||||||
|
|
||||||
|
for webhook in all_webhooks:
|
||||||
|
preset_id = webhook["preset_id"]
|
||||||
|
webhook_id = webhook["webhook_id"]
|
||||||
|
|
||||||
|
if preset_id not in enabled_presets:
|
||||||
|
enabled_presets[preset_id] = []
|
||||||
|
enabled_presets[preset_id].append(webhook_id)
|
||||||
|
|
||||||
|
return enabled_presets
|
||||||
|
|
||||||
|
# Fallback to API query
|
||||||
|
registered_webhooks = await webhooks_client.list_webhooks()
|
||||||
|
webhook_uri = _get_webhook_uri()
|
||||||
|
|
||||||
|
# Group webhooks by preset based on matching events
|
||||||
|
enabled_presets: dict[str, list[int]] = {}
|
||||||
|
|
||||||
|
for preset_id, preset in WEBHOOK_PRESETS.items():
|
||||||
|
preset_event_classes = {event["event"] for event in preset["events"]}
|
||||||
|
matching_webhooks = []
|
||||||
|
|
||||||
|
for webhook in registered_webhooks:
|
||||||
|
# Check if webhook matches this preset
|
||||||
|
if (
|
||||||
|
webhook.get("uri") == webhook_uri
|
||||||
|
and webhook.get("event") in preset_event_classes
|
||||||
|
):
|
||||||
|
matching_webhooks.append(webhook["id"])
|
||||||
|
|
||||||
|
if matching_webhooks:
|
||||||
|
enabled_presets[preset_id] = matching_webhooks
|
||||||
|
|
||||||
|
return enabled_presets
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list webhooks: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@requires("authenticated", redirect="oauth_login")
|
||||||
|
async def webhook_management_pane(request: Request) -> HTMLResponse:
|
||||||
|
"""Webhook management pane - returns HTML for webhook configuration.
|
||||||
|
|
||||||
|
This endpoint checks if the user is an admin and returns either:
|
||||||
|
- Admin view: Webhook management interface with preset controls
|
||||||
|
- Non-admin view: Message indicating admin-only access
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML response with webhook management interface or access denied message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get authenticated HTTP client
|
||||||
|
http_client = await _get_authenticated_client(request)
|
||||||
|
username = request.user.display_name
|
||||||
|
|
||||||
|
# Check admin permissions
|
||||||
|
is_admin = await is_nextcloud_admin(request, http_client)
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
return HTMLResponse(
|
||||||
|
content="""
|
||||||
|
<div class="info-message">
|
||||||
|
<p><strong>Admin Access Required</strong></p>
|
||||||
|
<p>Webhook management is only available to Nextcloud administrators.</p>
|
||||||
|
<p>Your account does not have admin privileges.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get webhooks client
|
||||||
|
webhooks_client = WebhooksClient(http_client, username)
|
||||||
|
|
||||||
|
# Get storage for database-backed webhook tracking
|
||||||
|
storage = _get_storage(request)
|
||||||
|
|
||||||
|
# Get installed apps to filter presets
|
||||||
|
installed_apps = await _get_installed_apps(http_client)
|
||||||
|
logger.debug(f"Installed apps: {installed_apps}")
|
||||||
|
|
||||||
|
# Get currently enabled presets (from database or API)
|
||||||
|
enabled_presets = await _get_enabled_presets(webhooks_client, storage)
|
||||||
|
|
||||||
|
# Filter presets based on installed apps
|
||||||
|
available_presets = filter_presets_by_installed_apps(installed_apps)
|
||||||
|
|
||||||
|
# Build preset cards HTML
|
||||||
|
preset_cards_html = ""
|
||||||
|
for preset_id, preset in available_presets:
|
||||||
|
is_enabled = preset_id in enabled_presets
|
||||||
|
num_webhooks = len(enabled_presets.get(preset_id, []))
|
||||||
|
|
||||||
|
# Status badge
|
||||||
|
if is_enabled:
|
||||||
|
status_badge = f'<span style="color: #4caf50; font-weight: bold;">✓ Enabled ({num_webhooks} webhooks)</span>'
|
||||||
|
action_button = f"""
|
||||||
|
<button
|
||||||
|
hx-delete="/app/webhooks/disable/{preset_id}"
|
||||||
|
hx-target="#preset-{preset_id}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="button"
|
||||||
|
style="background-color: #ff9800;">
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
status_badge = '<span style="color: #999;">Not Enabled</span>'
|
||||||
|
action_button = f"""
|
||||||
|
<button
|
||||||
|
hx-post="/app/webhooks/enable/{preset_id}"
|
||||||
|
hx-target="#preset-{preset_id}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="button button-primary">
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
|
||||||
|
preset_cards_html += f"""
|
||||||
|
<div id="preset-{preset_id}" style="border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin: 15px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #0082c9;">{preset["name"]}</h3>
|
||||||
|
<p style="color: #666; margin: 10px 0;">{preset["description"]}</p>
|
||||||
|
<p style="font-size: 13px; color: #999;">
|
||||||
|
<strong>App:</strong> {preset["app"]} |
|
||||||
|
<strong>Events:</strong> {len(preset["events"])}
|
||||||
|
</p>
|
||||||
|
<div style="margin-top: 15px; display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div>{status_badge}</div>
|
||||||
|
<div>{action_button}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get webhook endpoint URL for display
|
||||||
|
webhook_uri = _get_webhook_uri()
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<h2>Webhook Management</h2>
|
||||||
|
<div class="info-message">
|
||||||
|
<p><strong>About Webhooks</strong></p>
|
||||||
|
<p>Webhooks enable real-time synchronization by notifying this server when content changes in Nextcloud.</p>
|
||||||
|
<p><strong>Endpoint:</strong> <code>{webhook_uri}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 30px;">Available Presets</h3>
|
||||||
|
<p style="color: #666;">Enable webhook presets with one click for common synchronization scenarios.</p>
|
||||||
|
<p style="color: #999; font-size: 13px; margin-top: 5px;">Showing {len(available_presets)} preset(s) for your installed apps ({len(installed_apps)} detected)</p>
|
||||||
|
|
||||||
|
{preset_cards_html}
|
||||||
|
"""
|
||||||
|
|
||||||
|
return HTMLResponse(content=html_content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading webhook management pane: {e}", exc_info=True)
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f"""
|
||||||
|
<div class="warning">
|
||||||
|
<p><strong>Error Loading Webhooks</strong></p>
|
||||||
|
<p>{str(e)}</p>
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@requires("authenticated", redirect="oauth_login")
|
||||||
|
async def enable_webhook_preset(request: Request) -> HTMLResponse:
|
||||||
|
"""Enable a webhook preset by registering all webhooks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object (preset_id in path)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML response with updated preset card
|
||||||
|
"""
|
||||||
|
preset_id = request.path_params["preset_id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get authenticated HTTP client
|
||||||
|
http_client = await _get_authenticated_client(request)
|
||||||
|
username = request.user.display_name
|
||||||
|
|
||||||
|
# Check admin permissions
|
||||||
|
is_admin = await is_nextcloud_admin(request, http_client)
|
||||||
|
if not is_admin:
|
||||||
|
return HTMLResponse(
|
||||||
|
content='<div class="warning">Admin access required</div>',
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get preset configuration
|
||||||
|
preset = get_preset(preset_id)
|
||||||
|
if not preset:
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f'<div class="warning">Unknown preset: {preset_id}</div>',
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register webhooks
|
||||||
|
webhooks_client = WebhooksClient(http_client, username)
|
||||||
|
webhook_uri = _get_webhook_uri()
|
||||||
|
registered_ids = []
|
||||||
|
|
||||||
|
for event_config in preset["events"]:
|
||||||
|
webhook_data = await webhooks_client.create_webhook(
|
||||||
|
event=event_config["event"],
|
||||||
|
uri=webhook_uri,
|
||||||
|
event_filter=event_config["filter"] if event_config["filter"] else None,
|
||||||
|
)
|
||||||
|
webhook_id = webhook_data["id"]
|
||||||
|
registered_ids.append(webhook_id)
|
||||||
|
logger.info(f"Registered webhook {webhook_id} for {event_config['event']}")
|
||||||
|
|
||||||
|
# Persist webhook IDs to database
|
||||||
|
storage = _get_storage(request)
|
||||||
|
if storage:
|
||||||
|
for webhook_id in registered_ids:
|
||||||
|
await storage.store_webhook(webhook_id, preset_id)
|
||||||
|
logger.info(
|
||||||
|
f"Persisted {len(registered_ids)} webhook(s) for preset '{preset_id}' to database"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return updated card
|
||||||
|
num_webhooks = len(registered_ids)
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f"""
|
||||||
|
<div id="preset-{preset_id}" style="border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin: 15px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #0082c9;">{preset["name"]}</h3>
|
||||||
|
<p style="color: #666; margin: 10px 0;">{preset["description"]}</p>
|
||||||
|
<p style="font-size: 13px; color: #999;">
|
||||||
|
<strong>App:</strong> {preset["app"]} |
|
||||||
|
<strong>Events:</strong> {len(preset["events"])}
|
||||||
|
</p>
|
||||||
|
<div style="margin-top: 15px; display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div><span style="color: #4caf50; font-weight: bold;">✓ Enabled ({num_webhooks} webhooks)</span></div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
hx-delete="/app/webhooks/disable/{preset_id}"
|
||||||
|
hx-target="#preset-{preset_id}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="button"
|
||||||
|
style="background-color: #ff9800;">
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enable preset {preset_id}: {e}", exc_info=True)
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f'<div class="warning">Failed to enable preset: {str(e)}</div>',
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@requires("authenticated", redirect="oauth_login")
|
||||||
|
async def disable_webhook_preset(request: Request) -> HTMLResponse:
|
||||||
|
"""Disable a webhook preset by deleting all registered webhooks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object (preset_id in path)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML response with updated preset card
|
||||||
|
"""
|
||||||
|
preset_id = request.path_params["preset_id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get authenticated HTTP client
|
||||||
|
http_client = await _get_authenticated_client(request)
|
||||||
|
username = request.user.display_name
|
||||||
|
|
||||||
|
# Check admin permissions
|
||||||
|
is_admin = await is_nextcloud_admin(request, http_client)
|
||||||
|
if not is_admin:
|
||||||
|
return HTMLResponse(
|
||||||
|
content='<div class="warning">Admin access required</div>',
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get preset configuration
|
||||||
|
preset = get_preset(preset_id)
|
||||||
|
if not preset:
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f'<div class="warning">Unknown preset: {preset_id}</div>',
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find and delete matching webhooks
|
||||||
|
webhooks_client = WebhooksClient(http_client, username)
|
||||||
|
|
||||||
|
# Get webhook IDs from database first (more reliable)
|
||||||
|
storage = _get_storage(request)
|
||||||
|
if storage:
|
||||||
|
webhook_ids = await storage.get_webhooks_by_preset(preset_id)
|
||||||
|
else:
|
||||||
|
# Fallback to API query if storage not available
|
||||||
|
enabled_presets = await _get_enabled_presets(webhooks_client)
|
||||||
|
webhook_ids = enabled_presets.get(preset_id, [])
|
||||||
|
|
||||||
|
for webhook_id in webhook_ids:
|
||||||
|
await webhooks_client.delete_webhook(webhook_id)
|
||||||
|
logger.info(f"Deleted webhook {webhook_id} from preset {preset_id}")
|
||||||
|
|
||||||
|
# Remove from database
|
||||||
|
if storage:
|
||||||
|
deleted_count = await storage.clear_preset_webhooks(preset_id)
|
||||||
|
logger.info(
|
||||||
|
f"Removed {deleted_count} webhook(s) for preset '{preset_id}' from database"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return updated card
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f"""
|
||||||
|
<div id="preset-{preset_id}" style="border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; margin: 15px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #0082c9;">{preset["name"]}</h3>
|
||||||
|
<p style="color: #666; margin: 10px 0;">{preset["description"]}</p>
|
||||||
|
<p style="font-size: 13px; color: #999;">
|
||||||
|
<strong>App:</strong> {preset["app"]} |
|
||||||
|
<strong>Events:</strong> {len(preset["events"])}
|
||||||
|
</p>
|
||||||
|
<div style="margin-top: 15px; display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div><span style="color: #999;">Not Enabled</span></div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
hx-post="/app/webhooks/enable/{preset_id}"
|
||||||
|
hx-target="#preset-{preset_id}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="button button-primary">
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to disable preset {preset_id}: {e}", exc_info=True)
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f'<div class="warning">Failed to disable preset: {str(e)}</div>',
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import (
|
||||||
|
get_settings,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
||||||
|
|
||||||
|
from .app import get_app
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--host", "-h", default="127.0.0.1", show_default=True, help="Server host"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--log-level",
|
||||||
|
"-l",
|
||||||
|
default="info",
|
||||||
|
show_default=True,
|
||||||
|
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
|
||||||
|
help="Logging level",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--transport",
|
||||||
|
"-t",
|
||||||
|
default="sse",
|
||||||
|
show_default=True,
|
||||||
|
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||||
|
help="MCP transport protocol",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--enable-app",
|
||||||
|
"-e",
|
||||||
|
multiple=True,
|
||||||
|
type=click.Choice(
|
||||||
|
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
|
||||||
|
),
|
||||||
|
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth/--no-oauth",
|
||||||
|
default=None,
|
||||||
|
help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth-client-id",
|
||||||
|
envvar="NEXTCLOUD_OIDC_CLIENT_ID",
|
||||||
|
help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth-client-secret",
|
||||||
|
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
|
||||||
|
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--mcp-server-url",
|
||||||
|
envvar="NEXTCLOUD_MCP_SERVER_URL",
|
||||||
|
default="http://localhost:8000",
|
||||||
|
show_default=True,
|
||||||
|
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--nextcloud-host",
|
||||||
|
envvar="NEXTCLOUD_HOST",
|
||||||
|
help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--nextcloud-username",
|
||||||
|
envvar="NEXTCLOUD_USERNAME",
|
||||||
|
help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--nextcloud-password",
|
||||||
|
envvar="NEXTCLOUD_PASSWORD",
|
||||||
|
help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth-scopes",
|
||||||
|
envvar="NEXTCLOUD_OIDC_SCOPES",
|
||||||
|
default="openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
|
||||||
|
show_default=True,
|
||||||
|
help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth-token-type",
|
||||||
|
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
|
||||||
|
default="bearer",
|
||||||
|
show_default=True,
|
||||||
|
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
|
||||||
|
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--public-issuer-url",
|
||||||
|
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
|
||||||
|
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
|
||||||
|
)
|
||||||
|
def run(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
log_level: str,
|
||||||
|
transport: str,
|
||||||
|
enable_app: tuple[str, ...],
|
||||||
|
oauth: bool | None,
|
||||||
|
oauth_client_id: str | None,
|
||||||
|
oauth_client_secret: str | None,
|
||||||
|
mcp_server_url: str,
|
||||||
|
nextcloud_host: str | None,
|
||||||
|
nextcloud_username: str | None,
|
||||||
|
nextcloud_password: str | None,
|
||||||
|
oauth_scopes: str,
|
||||||
|
oauth_token_type: str,
|
||||||
|
public_issuer_url: str | None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Run the Nextcloud MCP server.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Authentication Modes:
|
||||||
|
- BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD
|
||||||
|
- OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled)
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
# BasicAuth mode with CLI options
|
||||||
|
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
|
||||||
|
--nextcloud-username=admin --nextcloud-password=secret
|
||||||
|
|
||||||
|
# BasicAuth mode with env vars (recommended for credentials)
|
||||||
|
$ export NEXTCLOUD_HOST=https://cloud.example.com
|
||||||
|
$ export NEXTCLOUD_USERNAME=admin
|
||||||
|
$ export NEXTCLOUD_PASSWORD=secret
|
||||||
|
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# OAuth mode with auto-registration
|
||||||
|
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
|
||||||
|
|
||||||
|
# OAuth mode with pre-configured client
|
||||||
|
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||||
|
--oauth-client-id=xxx --oauth-client-secret=yyy
|
||||||
|
|
||||||
|
# OAuth mode with custom scopes and JWT tokens
|
||||||
|
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||||
|
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
|
||||||
|
|
||||||
|
# OAuth with public issuer URL (for Docker/proxy setups)
|
||||||
|
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
||||||
|
--public-issuer-url=http://localhost:8080
|
||||||
|
"""
|
||||||
|
# Set env vars from CLI options if provided
|
||||||
|
if nextcloud_host:
|
||||||
|
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
|
||||||
|
if nextcloud_username:
|
||||||
|
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
|
||||||
|
if nextcloud_password:
|
||||||
|
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
|
||||||
|
if oauth_client_id:
|
||||||
|
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
|
||||||
|
if oauth_client_secret:
|
||||||
|
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
|
||||||
|
if oauth_scopes:
|
||||||
|
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
|
||||||
|
if oauth_token_type:
|
||||||
|
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
|
||||||
|
if mcp_server_url:
|
||||||
|
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
|
||||||
|
if public_issuer_url:
|
||||||
|
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
|
||||||
|
|
||||||
|
# Force OAuth mode if explicitly requested
|
||||||
|
if oauth is True:
|
||||||
|
# Clear username/password to force OAuth mode
|
||||||
|
if "NEXTCLOUD_USERNAME" in os.environ:
|
||||||
|
click.echo(
|
||||||
|
"Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True
|
||||||
|
)
|
||||||
|
del os.environ["NEXTCLOUD_USERNAME"]
|
||||||
|
if "NEXTCLOUD_PASSWORD" in os.environ:
|
||||||
|
click.echo(
|
||||||
|
"Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True
|
||||||
|
)
|
||||||
|
del os.environ["NEXTCLOUD_PASSWORD"]
|
||||||
|
|
||||||
|
# Validate OAuth configuration
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise click.ClickException(
|
||||||
|
"OAuth mode requires NEXTCLOUD_HOST environment variable to be set"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we have client credentials OR if dynamic registration is possible
|
||||||
|
has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv(
|
||||||
|
"NEXTCLOUD_OIDC_CLIENT_SECRET"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_client_creds:
|
||||||
|
# No client credentials - will attempt dynamic registration
|
||||||
|
# Show helpful message before server starts
|
||||||
|
click.echo("", err=True)
|
||||||
|
click.echo("OAuth Configuration:", err=True)
|
||||||
|
click.echo(" Mode: Dynamic Client Registration", err=True)
|
||||||
|
click.echo(" Host: " + nextcloud_host, err=True)
|
||||||
|
click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True)
|
||||||
|
click.echo("", err=True)
|
||||||
|
click.echo(
|
||||||
|
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
|
||||||
|
)
|
||||||
|
click.echo(" in your Nextcloud OIDC app settings.", err=True)
|
||||||
|
click.echo("", err=True)
|
||||||
|
else:
|
||||||
|
click.echo("", err=True)
|
||||||
|
click.echo("OAuth Configuration:", err=True)
|
||||||
|
click.echo(" Mode: Pre-configured Client", err=True)
|
||||||
|
click.echo(" Host: " + nextcloud_host, err=True)
|
||||||
|
click.echo(
|
||||||
|
" Client ID: "
|
||||||
|
+ os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16]
|
||||||
|
+ "...",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
click.echo("", err=True)
|
||||||
|
|
||||||
|
elif oauth is False:
|
||||||
|
# Force BasicAuth mode - verify credentials exist
|
||||||
|
if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"):
|
||||||
|
raise click.ClickException(
|
||||||
|
"--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set"
|
||||||
|
)
|
||||||
|
|
||||||
|
enabled_apps = list(enable_app) if enable_app else None
|
||||||
|
|
||||||
|
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||||
|
|
||||||
|
# Get observability settings and create uvicorn logging config
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn_log_config = get_uvicorn_logging_config(
|
||||||
|
log_format=settings.log_format,
|
||||||
|
log_level=settings.log_level,
|
||||||
|
include_trace_context=settings.log_include_trace_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app=app,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
log_level=log_level,
|
||||||
|
log_config=uvicorn_log_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -23,6 +23,7 @@ from .sharing import SharingClient
|
|||||||
from .tables import TablesClient
|
from .tables import TablesClient
|
||||||
from .users import UsersClient
|
from .users import UsersClient
|
||||||
from .webdav import WebDAVClient
|
from .webdav import WebDAVClient
|
||||||
|
from .webhooks import WebhooksClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ class NextcloudClient:
|
|||||||
self.users = UsersClient(self._client, username)
|
self.users = UsersClient(self._client, username)
|
||||||
self.groups = GroupsClient(self._client, username)
|
self.groups = GroupsClient(self._client, username)
|
||||||
self.sharing = SharingClient(self._client, username)
|
self.sharing = SharingClient(self._client, username)
|
||||||
|
self.webhooks = WebhooksClient(self._client, username)
|
||||||
|
|
||||||
# Initialize controllers
|
# Initialize controllers
|
||||||
self._notes_search = NotesSearchController()
|
self._notes_search = NotesSearchController()
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Client for Nextcloud Webhook Listeners API operations."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||||
|
|
||||||
|
|
||||||
|
class WebhooksClient(BaseNextcloudClient):
|
||||||
|
"""Client for Nextcloud webhook_listeners app API operations."""
|
||||||
|
|
||||||
|
app_name = "webhooks"
|
||||||
|
|
||||||
|
def _get_webhook_headers(
|
||||||
|
self, additional_headers: Optional[Dict[str, str]] = None
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Get standard headers required for Webhook Listeners API calls."""
|
||||||
|
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
|
||||||
|
if additional_headers:
|
||||||
|
headers.update(additional_headers)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def list_webhooks(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List all registered webhooks for the current user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of webhook registrations with id, uri, event, filters, etc.
|
||||||
|
"""
|
||||||
|
headers = self._get_webhook_headers()
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET",
|
||||||
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
data = response.json()["ocs"]["data"]
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
async def create_webhook(
|
||||||
|
self,
|
||||||
|
event: str,
|
||||||
|
uri: str,
|
||||||
|
http_method: str = "POST",
|
||||||
|
auth_method: str = "none",
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
event_filter: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Register a new webhook for the specified event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Fully qualified event class name (e.g., "OCP\\Files\\Events\\Node\\NodeCreatedEvent")
|
||||||
|
uri: Webhook endpoint URL to receive event notifications
|
||||||
|
http_method: HTTP method for webhook delivery (default: "POST")
|
||||||
|
auth_method: Authentication method ("none", "bearer", etc.)
|
||||||
|
headers: Custom headers to include in webhook requests (e.g., Authorization header)
|
||||||
|
event_filter: JSON object specifying event filters (e.g., {"user.uid": "bob"})
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Webhook registration details including webhook ID
|
||||||
|
"""
|
||||||
|
data: Dict[str, Any] = {
|
||||||
|
"httpMethod": http_method,
|
||||||
|
"uri": uri,
|
||||||
|
"event": event,
|
||||||
|
"authMethod": auth_method,
|
||||||
|
}
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
data["headers"] = headers
|
||||||
|
|
||||||
|
if event_filter:
|
||||||
|
data["eventFilter"] = event_filter
|
||||||
|
|
||||||
|
request_headers = self._get_webhook_headers()
|
||||||
|
response = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
|
||||||
|
json=data,
|
||||||
|
headers=request_headers,
|
||||||
|
)
|
||||||
|
return response.json()["ocs"]["data"]
|
||||||
|
|
||||||
|
async def delete_webhook(self, webhook_id: int) -> None:
|
||||||
|
"""Delete a webhook registration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhook_id: ID of the webhook to delete
|
||||||
|
"""
|
||||||
|
headers = self._get_webhook_headers()
|
||||||
|
await self._make_request(
|
||||||
|
"DELETE",
|
||||||
|
f"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{webhook_id}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_webhook(self, webhook_id: int) -> Dict[str, Any]:
|
||||||
|
"""Get details of a specific webhook registration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhook_id: ID of the webhook to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Webhook registration details
|
||||||
|
"""
|
||||||
|
headers = self._get_webhook_headers()
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET",
|
||||||
|
f"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{webhook_id}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
return response.json()["ocs"]["data"]
|
||||||
@@ -153,7 +153,13 @@ class Settings:
|
|||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||||
|
|
||||||
# Token settings
|
# Token and webhook storage settings
|
||||||
|
# TOKEN_ENCRYPTION_KEY: Optional - Only required for OAuth token storage operations.
|
||||||
|
# Webhook tracking works without encryption key.
|
||||||
|
# If set, must be a valid base64-encoded Fernet key (32 bytes).
|
||||||
|
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
|
||||||
|
# Used for webhook tracking (all modes) and OAuth token storage.
|
||||||
|
# Defaults to /tmp/tokens.db
|
||||||
token_encryption_key: Optional[str] = None
|
token_encryption_key: Optional[str] = None
|
||||||
token_storage_db: Optional[str] = None
|
token_storage_db: Optional[str] = None
|
||||||
|
|
||||||
@@ -186,7 +192,7 @@ class Settings:
|
|||||||
otel_service_name: str = "nextcloud-mcp-server"
|
otel_service_name: str = "nextcloud-mcp-server"
|
||||||
otel_traces_sampler: str = "always_on"
|
otel_traces_sampler: str = "always_on"
|
||||||
otel_traces_sampler_arg: float = 1.0
|
otel_traces_sampler_arg: float = 1.0
|
||||||
log_format: str = "json" # "json" or "text"
|
log_format: str = "text" # "json" or "text"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
log_include_trace_context: bool = True
|
log_include_trace_context: bool = True
|
||||||
|
|
||||||
@@ -204,7 +210,7 @@ class Settings:
|
|||||||
# Default to :memory: if neither set
|
# Default to :memory: if neither set
|
||||||
if not self.qdrant_url and not self.qdrant_location:
|
if not self.qdrant_url and not self.qdrant_location:
|
||||||
self.qdrant_location = ":memory:"
|
self.qdrant_location = ":memory:"
|
||||||
logger.info("Using default Qdrant mode: in-memory (:memory:)")
|
logger.debug("Using default Qdrant mode: in-memory (:memory:)")
|
||||||
|
|
||||||
# Warn if API key set in local mode
|
# Warn if API key set in local mode
|
||||||
if self.qdrant_location and self.qdrant_api_key:
|
if self.qdrant_location and self.qdrant_api_key:
|
||||||
@@ -305,7 +311,7 @@ def get_settings() -> Settings:
|
|||||||
),
|
),
|
||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||||
# Token settings
|
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
||||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
||||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
||||||
# Vector sync settings (ADR-007)
|
# Vector sync settings (ADR-007)
|
||||||
@@ -340,7 +346,7 @@ def get_settings() -> Settings:
|
|||||||
otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"),
|
otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"),
|
||||||
otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"),
|
otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"),
|
||||||
otel_traces_sampler_arg=float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")),
|
otel_traces_sampler_arg=float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")),
|
||||||
log_format=os.getenv("LOG_FORMAT", "json"),
|
log_format=os.getenv("LOG_FORMAT", "text"),
|
||||||
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
||||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
||||||
== "true",
|
== "true",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class OllamaEmbeddingProvider(EmbeddingProvider):
|
|||||||
f"Initialized Ollama provider: {base_url} (model={model}, verify_ssl={verify_ssl})"
|
f"Initialized Ollama provider: {base_url} (model={model}, verify_ssl={verify_ssl})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._check_model_is_loaded(autoload=True)
|
||||||
|
|
||||||
async def embed(self, text: str) -> list[float]:
|
async def embed(self, text: str) -> list[float]:
|
||||||
"""
|
"""
|
||||||
Generate embedding vector for text.
|
Generate embedding vector for text.
|
||||||
@@ -80,6 +82,23 @@ class OllamaEmbeddingProvider(EmbeddingProvider):
|
|||||||
"""
|
"""
|
||||||
return self._dimension
|
return self._dimension
|
||||||
|
|
||||||
|
def _check_model_is_loaded(self, autoload: bool = True):
|
||||||
|
response = httpx.get(f"{self.base_url}/api/tags")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
models = [model["name"] for model in response.json().get("models", [])]
|
||||||
|
logger.info("Ollama has following models pre-loaded: %s", models)
|
||||||
|
|
||||||
|
if (self.model not in models) and autoload:
|
||||||
|
logger.warning(
|
||||||
|
"Embedding model '%s' not yet available in ollama, attempting to pull now...",
|
||||||
|
self.model,
|
||||||
|
)
|
||||||
|
response = httpx.post(
|
||||||
|
f"{self.base_url}/api/pull", json={"model": self.model}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close HTTP client."""
|
"""Close HTTP client."""
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pythonjsonlogger import jsonlogger
|
from pythonjsonlogger.json import JsonFormatter
|
||||||
|
|
||||||
from nextcloud_mcp_server.observability.tracing import get_trace_context
|
from nextcloud_mcp_server.observability.tracing import get_trace_context
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class HealthCheckFilter(logging.Filter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TraceContextFormatter(jsonlogger.JsonFormatter):
|
class TraceContextFormatter(JsonFormatter):
|
||||||
"""
|
"""
|
||||||
JSON formatter that injects OpenTelemetry trace context into log records.
|
JSON formatter that injects OpenTelemetry trace context into log records.
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ def setup_logging(
|
|||||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
formatter = jsonlogger.JsonFormatter(
|
formatter = JsonFormatter(
|
||||||
"%(timestamp)s %(level)s %(name)s %(message)s",
|
"%(timestamp)s %(level)s %(name)s %(message)s",
|
||||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||||
)
|
)
|
||||||
@@ -251,7 +251,7 @@ def get_uvicorn_logging_config(
|
|||||||
if include_trace_context:
|
if include_trace_context:
|
||||||
formatter_class = "nextcloud_mcp_server.observability.logging_config.TraceContextFormatter"
|
formatter_class = "nextcloud_mcp_server.observability.logging_config.TraceContextFormatter"
|
||||||
else:
|
else:
|
||||||
formatter_class = "pythonjsonlogger.jsonlogger.JsonFormatter"
|
formatter_class = "pythonjsonlogger.json.JsonFormatter"
|
||||||
format_string = "%(timestamp)s %(level)s %(name)s %(message)s"
|
format_string = "%(timestamp)s %(level)s %(name)s %(message)s"
|
||||||
else:
|
else:
|
||||||
if include_trace_context:
|
if include_trace_context:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from mcp.server.fastmcp import Context
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Webhook preset configurations for common sync scenarios.
|
||||||
|
|
||||||
|
This module defines pre-configured webhook bundles that simplify
|
||||||
|
webhook setup for common use cases like Notes sync, Calendar sync, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookEventConfig(TypedDict):
|
||||||
|
"""Configuration for a single webhook event."""
|
||||||
|
|
||||||
|
event: str # Fully qualified event class name
|
||||||
|
filter: Dict[str, Any] # Event filter (optional)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookPreset(TypedDict):
|
||||||
|
"""Definition of a webhook preset."""
|
||||||
|
|
||||||
|
name: str # Display name
|
||||||
|
description: str # User-friendly description
|
||||||
|
events: List[WebhookEventConfig] # List of events to register
|
||||||
|
app: str # Nextcloud app this preset is for
|
||||||
|
|
||||||
|
|
||||||
|
# File/Notes webhook events
|
||||||
|
FILE_EVENT_CREATED = "OCP\\Files\\Events\\Node\\NodeCreatedEvent"
|
||||||
|
FILE_EVENT_WRITTEN = "OCP\\Files\\Events\\Node\\NodeWrittenEvent"
|
||||||
|
# Use BeforeNodeDeletedEvent instead of NodeDeletedEvent to get node.id
|
||||||
|
# See: https://github.com/nextcloud/server/issues/56371
|
||||||
|
FILE_EVENT_DELETED = "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent"
|
||||||
|
|
||||||
|
# Calendar webhook events
|
||||||
|
CALENDAR_EVENT_CREATED = "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent"
|
||||||
|
CALENDAR_EVENT_UPDATED = "OCP\\Calendar\\Events\\CalendarObjectUpdatedEvent"
|
||||||
|
CALENDAR_EVENT_DELETED = "OCP\\Calendar\\Events\\CalendarObjectDeletedEvent"
|
||||||
|
|
||||||
|
# Tables webhook events (Nextcloud 30+)
|
||||||
|
TABLES_EVENT_ROW_ADDED = "OCA\\Tables\\Event\\RowAddedEvent"
|
||||||
|
TABLES_EVENT_ROW_UPDATED = "OCA\\Tables\\Event\\RowUpdatedEvent"
|
||||||
|
TABLES_EVENT_ROW_DELETED = "OCA\\Tables\\Event\\RowDeletedEvent"
|
||||||
|
|
||||||
|
# Forms webhook events (Nextcloud 30+)
|
||||||
|
FORMS_EVENT_FORM_SUBMITTED = "OCA\\Forms\\Events\\FormSubmittedEvent"
|
||||||
|
|
||||||
|
# NOTE: Deck and Contacts do NOT support webhooks
|
||||||
|
# Their event classes do not implement IWebhookCompatibleEvent interface.
|
||||||
|
# Alternative sync strategies:
|
||||||
|
# - Deck: Use polling with ETag-based change detection
|
||||||
|
# - Contacts: Use CardDAV sync-token mechanism for efficient syncing
|
||||||
|
|
||||||
|
|
||||||
|
WEBHOOK_PRESETS: Dict[str, WebhookPreset] = {
|
||||||
|
"notes_sync": {
|
||||||
|
"name": "Notes Sync",
|
||||||
|
"description": "Real-time synchronization for Notes app (create, update, delete)",
|
||||||
|
"app": "notes",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event": FILE_EVENT_CREATED,
|
||||||
|
"filter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": FILE_EVENT_WRITTEN,
|
||||||
|
"filter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": FILE_EVENT_DELETED,
|
||||||
|
"filter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"calendar_sync": {
|
||||||
|
"name": "Calendar Sync",
|
||||||
|
"description": "Real-time synchronization for Calendar events (create, update, delete)",
|
||||||
|
"app": "calendar",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event": CALENDAR_EVENT_CREATED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": CALENDAR_EVENT_UPDATED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": CALENDAR_EVENT_DELETED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"tables_sync": {
|
||||||
|
"name": "Tables Sync",
|
||||||
|
"description": "Real-time synchronization for Tables rows (add, update, delete)",
|
||||||
|
"app": "tables",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event": TABLES_EVENT_ROW_ADDED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": TABLES_EVENT_ROW_UPDATED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": TABLES_EVENT_ROW_DELETED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"forms_sync": {
|
||||||
|
"name": "Forms Sync",
|
||||||
|
"description": "Real-time synchronization for Forms submissions",
|
||||||
|
"app": "forms",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event": FORMS_EVENT_FORM_SUBMITTED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"files_sync": {
|
||||||
|
"name": "All Files Sync",
|
||||||
|
"description": "Real-time synchronization for all file operations (create, update, delete)",
|
||||||
|
"app": "files",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event": FILE_EVENT_CREATED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": FILE_EVENT_WRITTEN,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": FILE_EVENT_DELETED,
|
||||||
|
"filter": {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset(preset_id: str) -> WebhookPreset | None:
|
||||||
|
"""Get a webhook preset by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset_id: Preset identifier (e.g., "notes_sync", "calendar_sync")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Webhook preset configuration or None if not found
|
||||||
|
"""
|
||||||
|
return WEBHOOK_PRESETS.get(preset_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_presets() -> List[tuple[str, WebhookPreset]]:
|
||||||
|
"""Get all available webhook presets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (preset_id, preset_config) tuples
|
||||||
|
"""
|
||||||
|
return list(WEBHOOK_PRESETS.items())
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_events(preset_id: str) -> List[str]:
|
||||||
|
"""Get list of event class names for a preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset_id: Preset identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of fully qualified event class names
|
||||||
|
"""
|
||||||
|
preset = get_preset(preset_id)
|
||||||
|
if not preset:
|
||||||
|
return []
|
||||||
|
return [event_config["event"] for event_config in preset["events"]]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_presets_by_installed_apps(
|
||||||
|
installed_apps: list[str],
|
||||||
|
) -> List[tuple[str, WebhookPreset]]:
|
||||||
|
"""Filter webhook presets to only show those for installed apps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
installed_apps: List of installed app names (e.g., ["notes", "calendar", "forms"])
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (preset_id, preset_config) tuples for presets whose apps are installed
|
||||||
|
"""
|
||||||
|
filtered = []
|
||||||
|
for preset_id, preset in WEBHOOK_PRESETS.items():
|
||||||
|
app_name = preset["app"]
|
||||||
|
# "files" is always available (core functionality)
|
||||||
|
if app_name == "files" or app_name in installed_apps:
|
||||||
|
filtered.append((preset_id, preset))
|
||||||
|
return filtered
|
||||||
+1
-1
@@ -116,7 +116,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
|
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
|
||||||
|
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
name = "testpypi"
|
name = "testpypi"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Unit tests for permission checking."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||||
|
from nextcloud_mcp_server.client.users import UsersClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request(mocker):
|
||||||
|
"""Create a mock Starlette request."""
|
||||||
|
request = mocker.Mock()
|
||||||
|
request.user = mocker.Mock()
|
||||||
|
request.user.display_name = "testuser"
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_http_client(mocker):
|
||||||
|
"""Create a mock HTTP client."""
|
||||||
|
return mocker.AsyncMock(spec=AsyncClient)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_is_nextcloud_admin_true(mock_request, mock_http_client, mocker):
|
||||||
|
"""Test checking if user is admin (admin group membership)."""
|
||||||
|
# Mock the get_user_groups method to return admin group
|
||||||
|
mock_get_user_groups = mocker.patch.object(
|
||||||
|
UsersClient, "get_user_groups", return_value=["admin", "users"]
|
||||||
|
)
|
||||||
|
|
||||||
|
is_admin = await is_nextcloud_admin(mock_request, mock_http_client)
|
||||||
|
|
||||||
|
assert is_admin is True
|
||||||
|
mock_get_user_groups.assert_called_once_with("testuser")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_is_nextcloud_admin_false(mock_request, mock_http_client, mocker):
|
||||||
|
"""Test checking if user is not admin (no admin group membership)."""
|
||||||
|
# Mock the get_user_groups method to return no admin group
|
||||||
|
mock_get_user_groups = mocker.patch.object(
|
||||||
|
UsersClient, "get_user_groups", return_value=["users", "editors"]
|
||||||
|
)
|
||||||
|
|
||||||
|
is_admin = await is_nextcloud_admin(mock_request, mock_http_client)
|
||||||
|
|
||||||
|
assert is_admin is False
|
||||||
|
mock_get_user_groups.assert_called_once_with("testuser")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_is_nextcloud_admin_empty_groups(mock_request, mock_http_client, mocker):
|
||||||
|
"""Test checking admin status when user has no groups."""
|
||||||
|
# Mock the get_user_groups method to return empty list
|
||||||
|
mock_get_user_groups = mocker.patch.object(
|
||||||
|
UsersClient, "get_user_groups", return_value=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
is_admin = await is_nextcloud_admin(mock_request, mock_http_client)
|
||||||
|
|
||||||
|
assert is_admin is False
|
||||||
|
mock_get_user_groups.assert_called_once_with("testuser")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_is_nextcloud_admin_no_username(mock_request, mock_http_client, mocker):
|
||||||
|
"""Test checking admin status when username is missing."""
|
||||||
|
# Set username to None
|
||||||
|
mock_request.user.display_name = None
|
||||||
|
|
||||||
|
mock_get_user_groups = mocker.patch.object(UsersClient, "get_user_groups")
|
||||||
|
|
||||||
|
is_admin = await is_nextcloud_admin(mock_request, mock_http_client)
|
||||||
|
|
||||||
|
assert is_admin is False
|
||||||
|
# Ensure get_user_groups was not called
|
||||||
|
mock_get_user_groups.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_is_nextcloud_admin_api_error(mock_request, mock_http_client, mocker):
|
||||||
|
"""Test checking admin status when API call fails."""
|
||||||
|
# Mock the get_user_groups method to raise an exception
|
||||||
|
mock_get_user_groups = mocker.patch.object(
|
||||||
|
UsersClient,
|
||||||
|
"get_user_groups",
|
||||||
|
side_effect=Exception("API error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
is_admin = await is_nextcloud_admin(mock_request, mock_http_client)
|
||||||
|
|
||||||
|
assert is_admin is False
|
||||||
|
mock_get_user_groups.assert_called_once_with("testuser")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_is_nextcloud_admin_case_sensitive(
|
||||||
|
mock_request, mock_http_client, mocker
|
||||||
|
):
|
||||||
|
"""Test that admin group check is case-sensitive."""
|
||||||
|
# Mock with "Admin" (capital A) instead of "admin"
|
||||||
|
mock_get_user_groups = mocker.patch.object(
|
||||||
|
UsersClient, "get_user_groups", return_value=["Admin", "users"]
|
||||||
|
)
|
||||||
|
|
||||||
|
is_admin = await is_nextcloud_admin(mock_request, mock_http_client)
|
||||||
|
|
||||||
|
# Should be False because Nextcloud uses lowercase "admin"
|
||||||
|
assert is_admin is False
|
||||||
|
mock_get_user_groups.assert_called_once_with("testuser")
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Unit tests for WebhooksClient."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def webhooks_client(mocker):
|
||||||
|
"""Create a WebhooksClient with mocked HTTP client."""
|
||||||
|
mock_http_client = mocker.AsyncMock(spec=AsyncClient)
|
||||||
|
return WebhooksClient(mock_http_client, "testuser")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_list_webhooks(webhooks_client, mocker):
|
||||||
|
"""Test listing registered webhooks."""
|
||||||
|
mock_response = mocker.Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"ocs": {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uri": "http://example.com/webhook",
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"httpMethod": "POST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"uri": "http://example.com/webhook",
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeWrittenEvent",
|
||||||
|
"httpMethod": "POST",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
WebhooksClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
webhooks = await webhooks_client.list_webhooks()
|
||||||
|
|
||||||
|
assert len(webhooks) == 2
|
||||||
|
assert webhooks[0]["id"] == 1
|
||||||
|
assert webhooks[0]["event"] == "OCP\\Files\\Events\\Node\\NodeCreatedEvent"
|
||||||
|
assert webhooks[1]["id"] == 2
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
|
||||||
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_list_webhooks_empty(webhooks_client, mocker):
|
||||||
|
"""Test listing webhooks when none are registered."""
|
||||||
|
mock_response = mocker.Mock()
|
||||||
|
mock_response.json.return_value = {"ocs": {"data": []}}
|
||||||
|
|
||||||
|
mocker.patch.object(WebhooksClient, "_make_request", return_value=mock_response)
|
||||||
|
|
||||||
|
webhooks = await webhooks_client.list_webhooks()
|
||||||
|
|
||||||
|
assert webhooks == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_webhook(webhooks_client, mocker):
|
||||||
|
"""Test creating a webhook registration."""
|
||||||
|
mock_response = mocker.Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"ocs": {
|
||||||
|
"data": {
|
||||||
|
"id": 123,
|
||||||
|
"uri": "http://example.com/webhook",
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"authMethod": "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
WebhooksClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_data = await webhooks_client.create_webhook(
|
||||||
|
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
uri="http://example.com/webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert webhook_data["id"] == 123
|
||||||
|
assert webhook_data["event"] == "OCP\\Files\\Events\\Node\\NodeCreatedEvent"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once()
|
||||||
|
call_args = mock_make_request.call_args
|
||||||
|
assert call_args[0][0] == "POST"
|
||||||
|
assert call_args[0][1] == "/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_webhook_with_filter(webhooks_client, mocker):
|
||||||
|
"""Test creating a webhook with event filter."""
|
||||||
|
mock_response = mocker.Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"ocs": {
|
||||||
|
"data": {
|
||||||
|
"id": 124,
|
||||||
|
"uri": "http://example.com/webhook",
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"eventFilter": {"user.uid": "bob"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
WebhooksClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_data = await webhooks_client.create_webhook(
|
||||||
|
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
uri="http://example.com/webhook",
|
||||||
|
event_filter={"user.uid": "bob"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert webhook_data["id"] == 124
|
||||||
|
assert webhook_data["eventFilter"] == {"user.uid": "bob"}
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once()
|
||||||
|
call_args = mock_make_request.call_args
|
||||||
|
assert call_args[1]["json"]["eventFilter"] == {"user.uid": "bob"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_webhook_with_auth_headers(webhooks_client, mocker):
|
||||||
|
"""Test creating a webhook with authentication headers."""
|
||||||
|
mock_response = mocker.Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"ocs": {
|
||||||
|
"data": {
|
||||||
|
"id": 125,
|
||||||
|
"uri": "http://example.com/webhook",
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"authMethod": "bearer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
WebhooksClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_data = await webhooks_client.create_webhook(
|
||||||
|
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
uri="http://example.com/webhook",
|
||||||
|
auth_method="bearer",
|
||||||
|
headers={"Authorization": "Bearer secret-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert webhook_data["id"] == 125
|
||||||
|
assert webhook_data["authMethod"] == "bearer"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once()
|
||||||
|
call_args = mock_make_request.call_args
|
||||||
|
assert call_args[1]["json"]["authMethod"] == "bearer"
|
||||||
|
assert call_args[1]["json"]["headers"] == {"Authorization": "Bearer secret-token"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_webhook(webhooks_client, mocker):
|
||||||
|
"""Test deleting a webhook registration."""
|
||||||
|
mock_response = mocker.Mock()
|
||||||
|
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
WebhooksClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
await webhooks_client.delete_webhook(webhook_id=123)
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"DELETE",
|
||||||
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/123",
|
||||||
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_webhook(webhooks_client, mocker):
|
||||||
|
"""Test getting a specific webhook by ID."""
|
||||||
|
mock_response = mocker.Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"ocs": {
|
||||||
|
"data": {
|
||||||
|
"id": 123,
|
||||||
|
"uri": "http://example.com/webhook",
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"httpMethod": "POST",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
WebhooksClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_data = await webhooks_client.get_webhook(webhook_id=123)
|
||||||
|
|
||||||
|
assert webhook_data["id"] == 123
|
||||||
|
assert webhook_data["event"] == "OCP\\Files\\Events\\Node\\NodeCreatedEvent"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/123",
|
||||||
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||||
|
)
|
||||||
@@ -28,7 +28,7 @@ import httpx
|
|||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
|
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
|
||||||
from tests.load.oauth_pool import (
|
from tests.load.oauth_pool import (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.token_exchange import TokenExchangeService
|
from nextcloud_mcp_server.auth.token_exchange import TokenExchangeService
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -5,7 +5,7 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from nextcloud_mcp_server.app import run
|
from nextcloud_mcp_server.cli import run
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -103,7 +103,7 @@ def test_cli_options_set_environment_variables(runner, clean_env, monkeypatch):
|
|||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
# Patch get_app to capture env vars
|
# Patch get_app to capture env vars
|
||||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
|
||||||
|
|
||||||
_ = runner.invoke(
|
_ = runner.invoke(
|
||||||
run,
|
run,
|
||||||
@@ -158,7 +158,7 @@ def test_cli_options_override_environment_variables(runner, monkeypatch):
|
|||||||
)
|
)
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
|
||||||
|
|
||||||
# Provide CLI options that should override env vars
|
# Provide CLI options that should override env vars
|
||||||
_ = runner.invoke(
|
_ = runner.invoke(
|
||||||
@@ -211,7 +211,7 @@ def test_environment_variables_used_when_cli_not_provided(runner, monkeypatch):
|
|||||||
)
|
)
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
|
||||||
|
|
||||||
# Don't provide any CLI options - should use env vars
|
# Don't provide any CLI options - should use env vars
|
||||||
_ = runner.invoke(run, [])
|
_ = runner.invoke(run, [])
|
||||||
@@ -243,7 +243,7 @@ def test_default_values(runner, clean_env, monkeypatch):
|
|||||||
)
|
)
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
|
||||||
|
|
||||||
# Don't provide CLI options or env vars - should use defaults
|
# Don't provide CLI options or env vars - should use defaults
|
||||||
_ = runner.invoke(run, [])
|
_ = runner.invoke(run, [])
|
||||||
@@ -275,7 +275,7 @@ def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
|
|||||||
)
|
)
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
|
||||||
|
|
||||||
# Test uppercase JWT
|
# Test uppercase JWT
|
||||||
runner.invoke(run, ["--oauth-token-type", "JWT"])
|
runner.invoke(run, ["--oauth-token-type", "JWT"])
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Unit tests for webhook preset filtering."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.server.webhook_presets import (
|
||||||
|
filter_presets_by_installed_apps,
|
||||||
|
get_preset,
|
||||||
|
list_presets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_all_presets():
|
||||||
|
"""Test listing all presets returns 5 presets."""
|
||||||
|
presets = list_presets()
|
||||||
|
assert len(presets) == 5
|
||||||
|
preset_ids = [preset_id for preset_id, _ in presets]
|
||||||
|
assert "notes_sync" in preset_ids
|
||||||
|
assert "calendar_sync" in preset_ids
|
||||||
|
assert "tables_sync" in preset_ids
|
||||||
|
assert "forms_sync" in preset_ids
|
||||||
|
assert "files_sync" in preset_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_preset_existing():
|
||||||
|
"""Test getting an existing preset."""
|
||||||
|
preset = get_preset("notes_sync")
|
||||||
|
assert preset is not None
|
||||||
|
assert preset["name"] == "Notes Sync"
|
||||||
|
assert preset["app"] == "notes"
|
||||||
|
assert len(preset["events"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_preset_nonexistent():
|
||||||
|
"""Test getting a nonexistent preset returns None."""
|
||||||
|
preset = get_preset("nonexistent_sync")
|
||||||
|
assert preset is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_filter_presets_all_apps_installed():
|
||||||
|
"""Test filtering when all apps are installed."""
|
||||||
|
installed_apps = ["notes", "calendar", "tables", "forms"]
|
||||||
|
filtered = filter_presets_by_installed_apps(installed_apps)
|
||||||
|
assert len(filtered) == 5 # All 5 presets (files is always included)
|
||||||
|
preset_ids = [preset_id for preset_id, _ in filtered]
|
||||||
|
assert "notes_sync" in preset_ids
|
||||||
|
assert "calendar_sync" in preset_ids
|
||||||
|
assert "tables_sync" in preset_ids
|
||||||
|
assert "forms_sync" in preset_ids
|
||||||
|
assert "files_sync" in preset_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_filter_presets_subset_installed():
|
||||||
|
"""Test filtering when only some apps are installed."""
|
||||||
|
installed_apps = ["notes", "calendar"]
|
||||||
|
filtered = filter_presets_by_installed_apps(installed_apps)
|
||||||
|
assert len(filtered) == 3 # notes, calendar, files
|
||||||
|
preset_ids = [preset_id for preset_id, _ in filtered]
|
||||||
|
assert "notes_sync" in preset_ids
|
||||||
|
assert "calendar_sync" in preset_ids
|
||||||
|
assert "files_sync" in preset_ids
|
||||||
|
assert "tables_sync" not in preset_ids
|
||||||
|
assert "forms_sync" not in preset_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_filter_presets_no_apps_installed():
|
||||||
|
"""Test filtering when no optional apps are installed."""
|
||||||
|
installed_apps = []
|
||||||
|
filtered = filter_presets_by_installed_apps(installed_apps)
|
||||||
|
assert len(filtered) == 1 # Only files
|
||||||
|
preset_ids = [preset_id for preset_id, _ in filtered]
|
||||||
|
assert "files_sync" in preset_ids
|
||||||
|
assert "notes_sync" not in preset_ids
|
||||||
|
assert "calendar_sync" not in preset_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_filter_presets_files_always_included():
|
||||||
|
"""Test that files preset is always included regardless of installed apps."""
|
||||||
|
# Empty list
|
||||||
|
filtered = filter_presets_by_installed_apps([])
|
||||||
|
preset_ids = [preset_id for preset_id, _ in filtered]
|
||||||
|
assert "files_sync" in preset_ids
|
||||||
|
|
||||||
|
# List with other apps but not explicitly "files"
|
||||||
|
filtered = filter_presets_by_installed_apps(["notes", "calendar"])
|
||||||
|
preset_ids = [preset_id for preset_id, _ in filtered]
|
||||||
|
assert "files_sync" in preset_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_filter_presets_forms_included_when_installed():
|
||||||
|
"""Test that forms preset is included when Forms app is installed."""
|
||||||
|
installed_apps = ["forms"]
|
||||||
|
filtered = filter_presets_by_installed_apps(installed_apps)
|
||||||
|
preset_ids = [preset_id for preset_id, _ in filtered]
|
||||||
|
assert "forms_sync" in preset_ids
|
||||||
|
assert len(filtered) == 2 # forms + files
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_filter_presets_forms_excluded_when_not_installed():
|
||||||
|
"""Test that forms preset is excluded when Forms app is not installed."""
|
||||||
|
installed_apps = ["notes", "calendar", "tables"]
|
||||||
|
filtered = filter_presets_by_installed_apps(installed_apps)
|
||||||
|
preset_ids = [preset_id for preset_id, _ in filtered]
|
||||||
|
assert "forms_sync" not in preset_ids
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Webhook Storage functionality.
|
||||||
|
|
||||||
|
Tests the webhook tracking methods in RefreshTokenStorage without
|
||||||
|
requiring real database connections or network calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def temp_storage():
|
||||||
|
"""Create temporary storage instance for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_webhooks.db"
|
||||||
|
# No encryption key needed for webhook tracking
|
||||||
|
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
|
||||||
|
await storage.initialize()
|
||||||
|
yield storage
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_webhook(temp_storage):
|
||||||
|
"""Test storing a webhook."""
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
|
||||||
|
webhooks = await temp_storage.list_all_webhooks()
|
||||||
|
assert len(webhooks) == 1
|
||||||
|
assert webhooks[0]["webhook_id"] == 123
|
||||||
|
assert webhooks[0]["preset_id"] == "notes_sync"
|
||||||
|
assert "created_at" in webhooks[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_webhook_duplicate(temp_storage):
|
||||||
|
"""Test storing duplicate webhook replaces existing."""
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="calendar_sync")
|
||||||
|
|
||||||
|
webhooks = await temp_storage.list_all_webhooks()
|
||||||
|
# Should only have one entry due to UNIQUE constraint
|
||||||
|
assert len(webhooks) == 1
|
||||||
|
assert webhooks[0]["preset_id"] == "calendar_sync"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_webhooks_by_preset(temp_storage):
|
||||||
|
"""Test retrieving webhooks by preset."""
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=456, preset_id="notes_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=789, preset_id="calendar_sync")
|
||||||
|
|
||||||
|
notes_webhooks = await temp_storage.get_webhooks_by_preset("notes_sync")
|
||||||
|
assert len(notes_webhooks) == 2
|
||||||
|
assert 123 in notes_webhooks
|
||||||
|
assert 456 in notes_webhooks
|
||||||
|
|
||||||
|
calendar_webhooks = await temp_storage.get_webhooks_by_preset("calendar_sync")
|
||||||
|
assert len(calendar_webhooks) == 1
|
||||||
|
assert 789 in calendar_webhooks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_webhooks_by_preset_empty(temp_storage):
|
||||||
|
"""Test retrieving webhooks for non-existent preset."""
|
||||||
|
webhooks = await temp_storage.get_webhooks_by_preset("nonexistent")
|
||||||
|
assert len(webhooks) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_webhook(temp_storage):
|
||||||
|
"""Test deleting a webhook."""
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=456, preset_id="notes_sync")
|
||||||
|
|
||||||
|
deleted = await temp_storage.delete_webhook(webhook_id=123)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
webhooks = await temp_storage.get_webhooks_by_preset("notes_sync")
|
||||||
|
assert len(webhooks) == 1
|
||||||
|
assert 456 in webhooks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_webhook_nonexistent(temp_storage):
|
||||||
|
"""Test deleting non-existent webhook."""
|
||||||
|
deleted = await temp_storage.delete_webhook(webhook_id=999)
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_all_webhooks(temp_storage):
|
||||||
|
"""Test listing all webhooks."""
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=456, preset_id="calendar_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=789, preset_id="notes_sync")
|
||||||
|
|
||||||
|
webhooks = await temp_storage.list_all_webhooks()
|
||||||
|
assert len(webhooks) == 3
|
||||||
|
|
||||||
|
# Verify all expected fields present
|
||||||
|
for webhook in webhooks:
|
||||||
|
assert "webhook_id" in webhook
|
||||||
|
assert "preset_id" in webhook
|
||||||
|
assert "created_at" in webhook
|
||||||
|
|
||||||
|
# Verify webhook IDs
|
||||||
|
webhook_ids = [w["webhook_id"] for w in webhooks]
|
||||||
|
assert 123 in webhook_ids
|
||||||
|
assert 456 in webhook_ids
|
||||||
|
assert 789 in webhook_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_all_webhooks_empty(temp_storage):
|
||||||
|
"""Test listing webhooks when none exist."""
|
||||||
|
webhooks = await temp_storage.list_all_webhooks()
|
||||||
|
assert len(webhooks) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clear_preset_webhooks(temp_storage):
|
||||||
|
"""Test clearing all webhooks for a preset."""
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=456, preset_id="notes_sync")
|
||||||
|
await temp_storage.store_webhook(webhook_id=789, preset_id="calendar_sync")
|
||||||
|
|
||||||
|
deleted_count = await temp_storage.clear_preset_webhooks("notes_sync")
|
||||||
|
assert deleted_count == 2
|
||||||
|
|
||||||
|
# Verify notes_sync webhooks are gone
|
||||||
|
notes_webhooks = await temp_storage.get_webhooks_by_preset("notes_sync")
|
||||||
|
assert len(notes_webhooks) == 0
|
||||||
|
|
||||||
|
# Verify calendar_sync webhook still exists
|
||||||
|
calendar_webhooks = await temp_storage.get_webhooks_by_preset("calendar_sync")
|
||||||
|
assert len(calendar_webhooks) == 1
|
||||||
|
assert 789 in calendar_webhooks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clear_preset_webhooks_nonexistent(temp_storage):
|
||||||
|
"""Test clearing webhooks for non-existent preset."""
|
||||||
|
deleted_count = await temp_storage.clear_preset_webhooks("nonexistent")
|
||||||
|
assert deleted_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_timestamps(temp_storage):
|
||||||
|
"""Test that webhook timestamps are properly stored."""
|
||||||
|
start_time = time.time()
|
||||||
|
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
end_time = time.time()
|
||||||
|
|
||||||
|
webhooks = await temp_storage.list_all_webhooks()
|
||||||
|
assert len(webhooks) == 1
|
||||||
|
|
||||||
|
created_at = webhooks[0]["created_at"]
|
||||||
|
assert start_time <= created_at <= end_time
|
||||||
|
|
||||||
|
|
||||||
|
async def test_storage_without_encryption_key():
|
||||||
|
"""Test that storage can be initialized without encryption key."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_no_encryption.db"
|
||||||
|
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
# Webhook operations should work without encryption key
|
||||||
|
await storage.store_webhook(webhook_id=123, preset_id="notes_sync")
|
||||||
|
webhooks = await storage.get_webhooks_by_preset("notes_sync")
|
||||||
|
assert len(webhooks) == 1
|
||||||
|
assert 123 in webhooks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multiple_presets_independence(temp_storage):
|
||||||
|
"""Test that different presets maintain independent webhook lists."""
|
||||||
|
presets = ["notes_sync", "calendar_sync", "deck_sync", "files_sync"]
|
||||||
|
|
||||||
|
# Store webhooks for each preset
|
||||||
|
for i, preset in enumerate(presets):
|
||||||
|
webhook_id = 100 + i
|
||||||
|
await temp_storage.store_webhook(webhook_id=webhook_id, preset_id=preset)
|
||||||
|
|
||||||
|
# Verify each preset has exactly one webhook
|
||||||
|
for i, preset in enumerate(presets):
|
||||||
|
webhooks = await temp_storage.get_webhooks_by_preset(preset)
|
||||||
|
assert len(webhooks) == 1
|
||||||
|
assert (100 + i) in webhooks
|
||||||
|
|
||||||
|
# Clear one preset
|
||||||
|
deleted = await temp_storage.clear_preset_webhooks("notes_sync")
|
||||||
|
assert deleted == 1
|
||||||
|
|
||||||
|
# Verify other presets unchanged
|
||||||
|
for preset in ["calendar_sync", "deck_sync", "files_sync"]:
|
||||||
|
webhooks = await temp_storage.get_webhooks_by_preset(preset)
|
||||||
|
assert len(webhooks) == 1
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
# Nextcloud Webhook Testing Findings
|
||||||
|
|
||||||
|
**Date:** 2025-11-11
|
||||||
|
**Purpose:** Manual validation of Nextcloud webhook schemas and behavior for vector sync integration (ADR-010)
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Successfully tested and validated Nextcloud webhook payloads for file/note events and calendar events. **5 out of 6** webhook types were captured and validated against expected schemas from ADR-010 and Nextcloud documentation. One calendar deletion webhook did not fire during testing (potential Nextcloud issue or configuration).
|
||||||
|
|
||||||
|
## Test Environment
|
||||||
|
|
||||||
|
- **Nextcloud Version:** 30+ (Docker compose setup)
|
||||||
|
- **Webhook App:** `webhook_listeners` (bundled, enabled)
|
||||||
|
- **MCP Server:** Test endpoint at `http://mcp:8000/webhooks/nextcloud`
|
||||||
|
- **Background Worker:** Running with 60s timeout
|
||||||
|
- **Authentication:** None (test environment)
|
||||||
|
|
||||||
|
## Webhooks Registered
|
||||||
|
|
||||||
|
| ID | Event Class | Status |
|
||||||
|
|----|------------|--------|
|
||||||
|
| 1 | `OCP\Files\Events\Node\NodeCreatedEvent` | ✓ Tested |
|
||||||
|
| 2 | `OCP\Files\Events\Node\NodeWrittenEvent` | ✓ Tested |
|
||||||
|
| 3 | `OCP\Files\Events\Node\NodeDeletedEvent` | ✓ Tested |
|
||||||
|
| 4 | `OCP\Calendar\Events\CalendarObjectCreatedEvent` | ✓ Tested |
|
||||||
|
| 5 | `OCP\Calendar\Events\CalendarObjectUpdatedEvent` | ✓ Tested |
|
||||||
|
| 6 | `OCP\Calendar\Events\CalendarObjectDeletedEvent` | ✗ Not received |
|
||||||
|
|
||||||
|
## Captured Webhook Payloads
|
||||||
|
|
||||||
|
### 1. NodeCreatedEvent (File/Note Creation)
|
||||||
|
|
||||||
|
**Test Action:** Created note via Notes API
|
||||||
|
**Trigger Time:** 2025-11-11 08:37:25
|
||||||
|
**Webhooks Fired:** 3 events (folder creation + file creation + file written)
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"uid": "admin",
|
||||||
|
"displayName": "admin"
|
||||||
|
},
|
||||||
|
"time": 1762850245,
|
||||||
|
"event": {
|
||||||
|
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"node": {
|
||||||
|
"id": 437,
|
||||||
|
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- ✅ Schema matches ADR-010 specification
|
||||||
|
- ✅ Contains `user` object with `uid` and `displayName`
|
||||||
|
- ✅ Contains `time` (Unix timestamp)
|
||||||
|
- ✅ Contains `event.class` (fully qualified event name)
|
||||||
|
- ✅ Contains `event.node.id` (file ID)
|
||||||
|
- ✅ Contains `event.node.path` (absolute path)
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
- Creating a note via Notes API triggers 3 webhook events:
|
||||||
|
1. `NodeCreatedEvent` for the parent folder (if new)
|
||||||
|
2. `NodeWrittenEvent` for the parent folder
|
||||||
|
3. `NodeCreatedEvent` for the actual file
|
||||||
|
4. `NodeWrittenEvent` for the file (sometimes fired 2x)
|
||||||
|
|
||||||
|
### 2. NodeWrittenEvent (File/Note Update)
|
||||||
|
|
||||||
|
**Test Action:** Updated note content via Notes API
|
||||||
|
**Trigger Time:** 2025-11-11 08:49:20
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"uid": "admin",
|
||||||
|
"displayName": "admin"
|
||||||
|
},
|
||||||
|
"time": 1762850960,
|
||||||
|
"event": {
|
||||||
|
"class": "OCP\\Files\\Events\\Node\\NodeWrittenEvent",
|
||||||
|
"node": {
|
||||||
|
"id": 437,
|
||||||
|
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- ✅ Schema identical to `NodeCreatedEvent` except for `event.class`
|
||||||
|
- ✅ Same file ID (437) as creation event
|
||||||
|
- ✅ Updated timestamp reflects actual modification time
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
- File updates trigger a single `NodeWrittenEvent`
|
||||||
|
- No duplicate events fired for update operations
|
||||||
|
|
||||||
|
### 3. NodeDeletedEvent (File/Note Deletion)
|
||||||
|
|
||||||
|
**Test Action:** Deleted note via Notes API
|
||||||
|
**Trigger Time:** 2025-11-11 08:51:34
|
||||||
|
**Webhooks Fired:** 2 events (file + folder deletion)
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"uid": "admin",
|
||||||
|
"displayName": "admin"
|
||||||
|
},
|
||||||
|
"time": 1762851093,
|
||||||
|
"event": {
|
||||||
|
"class": "OCP\\Files\\Events\\Node\\NodeDeletedEvent",
|
||||||
|
"node": {
|
||||||
|
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- ✅ Schema matches ADR-010 specification
|
||||||
|
- ⚠️ **IMPORTANT:** No `node.id` field in deletion events (only `path`)
|
||||||
|
- ✅ Folder deletion triggered after file deletion (empty folder cleanup)
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
- **Critical Difference:** Deletion events do NOT include `node.id`, only `node.path`
|
||||||
|
- This differs from Create/Write events which include both `id` and `path`
|
||||||
|
- ADR-010 implementation must handle missing `id` field for deletions
|
||||||
|
- Deleting a file also triggers deletion of empty parent folders
|
||||||
|
|
||||||
|
### 4. CalendarObjectCreatedEvent (Calendar Event Creation)
|
||||||
|
|
||||||
|
**Test Action:** Created calendar event via CalDAV PUT
|
||||||
|
**Trigger Time:** 2025-11-11 08:52:50
|
||||||
|
|
||||||
|
**Payload (partial - calendarData omitted for brevity):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"uid": "admin",
|
||||||
|
"displayName": "admin"
|
||||||
|
},
|
||||||
|
"time": 1762851169,
|
||||||
|
"event": {
|
||||||
|
"calendarId": 1,
|
||||||
|
"class": "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent",
|
||||||
|
"calendarData": {
|
||||||
|
"id": 1,
|
||||||
|
"uri": "personal",
|
||||||
|
"{http://calendarserver.org/ns/}getctag": "...",
|
||||||
|
"{http://sabredav.org/ns}sync-token": 21,
|
||||||
|
"{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set": [],
|
||||||
|
"{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp": [],
|
||||||
|
"{urn:ietf:params:xml:ns:caldav}calendar-timezone": null
|
||||||
|
},
|
||||||
|
"objectData": {
|
||||||
|
"id": 3,
|
||||||
|
"uri": "webhook-test-event-001.ics",
|
||||||
|
"lastmodified": 1762851169,
|
||||||
|
"etag": "\"2b937b7d77dc83c77329dfdb210ba9d0\"",
|
||||||
|
"calendarid": 1,
|
||||||
|
"size": 297,
|
||||||
|
"component": "vevent",
|
||||||
|
"classification": 0,
|
||||||
|
"uid": "webhook-test-event-001@nextcloud",
|
||||||
|
"calendardata": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...",
|
||||||
|
"{http://nextcloud.com/ns}deleted-at": null
|
||||||
|
},
|
||||||
|
"shares": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- ✅ Schema matches Nextcloud documentation
|
||||||
|
- ✅ Contains complete calendar metadata (`calendarData`)
|
||||||
|
- ✅ Contains complete event data (`objectData`)
|
||||||
|
- ✅ Includes full iCal data in `objectData.calendardata`
|
||||||
|
- ✅ Includes `objectData.id` for database lookups
|
||||||
|
- ⚠️ **Complex:** Much more metadata than file events
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
- Calendar webhooks include significantly more data than file webhooks
|
||||||
|
- Full iCal content is embedded in `objectData.calendardata`
|
||||||
|
- Event ID is in `objectData.id` (NOT `event.id`)
|
||||||
|
- `calendarData` contains calendar-level metadata
|
||||||
|
- `shares` array contains sharing information (empty in this test)
|
||||||
|
|
||||||
|
### 5. CalendarObjectUpdatedEvent (Calendar Event Update)
|
||||||
|
|
||||||
|
**Test Action:** Updated calendar event via CalDAV PUT
|
||||||
|
**Trigger Time:** 2025-11-11 08:53:28
|
||||||
|
|
||||||
|
**Payload (partial):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"uid": "admin",
|
||||||
|
"displayName": "admin"
|
||||||
|
},
|
||||||
|
"time": 1762851207,
|
||||||
|
"event": {
|
||||||
|
"calendarId": 1,
|
||||||
|
"class": "OCP\\Calendar\\Events\\CalendarObjectUpdatedEvent",
|
||||||
|
"calendarData": { /* same structure as creation */ },
|
||||||
|
"objectData": {
|
||||||
|
"id": 3,
|
||||||
|
"uri": "webhook-test-event-001.ics",
|
||||||
|
"lastmodified": 1762851207,
|
||||||
|
"etag": "\"2695a18013e0991e4212b07b61d5e1e2\"",
|
||||||
|
"calendarid": 1,
|
||||||
|
"size": 315,
|
||||||
|
"component": "vevent",
|
||||||
|
"classification": 0,
|
||||||
|
"uid": "webhook-test-event-001@nextcloud",
|
||||||
|
"calendardata": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...",
|
||||||
|
"{http://nextcloud.com/ns}deleted-at": null
|
||||||
|
},
|
||||||
|
"shares": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- ✅ Schema identical to `CalendarObjectCreatedEvent` except `event.class`
|
||||||
|
- ✅ Same event ID (3) as creation
|
||||||
|
- ✅ Updated `lastmodified` timestamp
|
||||||
|
- ✅ Different `etag` (changed from creation)
|
||||||
|
- ✅ Larger `size` (315 vs 297 bytes)
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
- Update events contain full new state (not delta)
|
||||||
|
- ETag changes on updates (useful for conflict detection)
|
||||||
|
- Size field reflects actual iCal size
|
||||||
|
|
||||||
|
### 6. CalendarObjectDeletedEvent (Calendar Event Deletion)
|
||||||
|
|
||||||
|
**Test Action:** Deleted calendar event via CalDAV DELETE
|
||||||
|
**Trigger Time:** 2025-11-11 08:54:47
|
||||||
|
**Status:** ❌ **WEBHOOK DID NOT FIRE**
|
||||||
|
|
||||||
|
**Expected Payload (from Nextcloud docs):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"uid": "admin",
|
||||||
|
"displayName": "admin"
|
||||||
|
},
|
||||||
|
"time": <timestamp>,
|
||||||
|
"event": {
|
||||||
|
"calendarId": 1,
|
||||||
|
"class": "OCP\\Calendar\\Events\\CalendarObjectDeletedEvent",
|
||||||
|
"calendarData": { /* calendar metadata */ },
|
||||||
|
"objectData": {
|
||||||
|
"id": 3,
|
||||||
|
"uri": "webhook-test-event-001.ics",
|
||||||
|
/* ... other fields ... */
|
||||||
|
},
|
||||||
|
"shares": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:**
|
||||||
|
- Calendar event was successfully deleted (verified via CalDAV PROPFIND)
|
||||||
|
- Webhook registration confirmed (ID #6 in `webhook_listeners:list`)
|
||||||
|
- Background worker running and processing other events
|
||||||
|
- **No webhook notification received after 2+ minutes**
|
||||||
|
|
||||||
|
**Possible Causes:**
|
||||||
|
1. Known Nextcloud bug with calendar deletion webhooks
|
||||||
|
2. CalDAV DELETE may not trigger event system properly
|
||||||
|
3. Deletion event may require trash bin enabled
|
||||||
|
4. Background job may have silently failed
|
||||||
|
|
||||||
|
**Recommended Actions:**
|
||||||
|
- File Nextcloud issue report
|
||||||
|
- Test with trash bin enabled (`CalendarObjectMovedToTrashEvent`)
|
||||||
|
- Check Nextcloud error logs for webhook failures
|
||||||
|
- Verify with Nextcloud 31+ if issue persists
|
||||||
|
|
||||||
|
## Schema Comparison: Expected vs Actual
|
||||||
|
|
||||||
|
### File Events
|
||||||
|
|
||||||
|
| Field | Expected (ADR-010) | Actual | Match |
|
||||||
|
|-------|-------------------|--------|-------|
|
||||||
|
| `user.uid` | string | string | ✅ |
|
||||||
|
| `user.displayName` | string | string | ✅ |
|
||||||
|
| `time` | int | int | ✅ |
|
||||||
|
| `event.class` | string | string | ✅ |
|
||||||
|
| `event.node.id` | string | int | ⚠️ Type mismatch |
|
||||||
|
| `event.node.path` | string | string | ✅ |
|
||||||
|
|
||||||
|
**Type Discrepancy:** `node.id` is documented as `string` but returns as `int` (437 instead of "437")
|
||||||
|
|
||||||
|
### Calendar Events
|
||||||
|
|
||||||
|
| Field | Expected (Nextcloud docs) | Actual | Match |
|
||||||
|
|-------|-------------------------|--------|-------|
|
||||||
|
| `user.uid` | string | string | ✅ |
|
||||||
|
| `user.displayName` | string | string | ✅ |
|
||||||
|
| `time` | int | int | ✅ |
|
||||||
|
| `event.class` | string | string | ✅ |
|
||||||
|
| `event.calendarId` | int | int | ✅ |
|
||||||
|
| `event.calendarData.*` | object | object | ✅ |
|
||||||
|
| `event.objectData.id` | int | int | ✅ |
|
||||||
|
| `event.objectData.uri` | string | string | ✅ |
|
||||||
|
| `event.objectData.calendardata` | string | string | ✅ |
|
||||||
|
| `event.objectData.lastmodified` | int | int | ✅ |
|
||||||
|
| `event.objectData.etag` | string | string | ✅ |
|
||||||
|
| `event.objectData.component` | string\|null | string | ✅ |
|
||||||
|
| `event.shares` | array | array | ✅ |
|
||||||
|
|
||||||
|
All calendar event fields match expected schemas.
|
||||||
|
|
||||||
|
## Key Findings for ADR-010 Implementation
|
||||||
|
|
||||||
|
### 1. Deletion Events Have Different Schema
|
||||||
|
- **File Deletions:** No `node.id` field, only `node.path`
|
||||||
|
- **Calendar Deletions:** Not tested (webhook didn't fire)
|
||||||
|
- **Impact:** Webhook handler must check for `node.id` existence before using it
|
||||||
|
|
||||||
|
### 2. Multiple Webhooks Per Operation
|
||||||
|
- Creating a note triggers 3-5 webhook events
|
||||||
|
- Deleting a note triggers 2 events (file + folder)
|
||||||
|
- **Impact:** Deduplication logic needed in webhook handler
|
||||||
|
|
||||||
|
### 3. Event-Specific ID Fields
|
||||||
|
- **File events:** `event.node.id`
|
||||||
|
- **Calendar events:** `event.objectData.id`
|
||||||
|
- **Impact:** Event parser must handle different ID field locations
|
||||||
|
|
||||||
|
### 4. Full State vs Delta
|
||||||
|
- All webhooks contain complete current state (not delta)
|
||||||
|
- **Impact:** No need for "previous state" tracking in webhook handler
|
||||||
|
|
||||||
|
### 5. Calendar Data Richness
|
||||||
|
- Calendar webhooks include full iCal content
|
||||||
|
- **Impact:** Can extract all event metadata without additional API calls
|
||||||
|
|
||||||
|
## Recommendations for ADR-010 Implementation
|
||||||
|
|
||||||
|
### 1. Webhook Event Parser (`webhook_parser.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def extract_document_task(event_class: str, payload: dict) -> DocumentTask | None:
|
||||||
|
"""Extract DocumentTask from webhook event payload."""
|
||||||
|
user_id = payload["user"]["uid"]
|
||||||
|
event_data = payload["event"]
|
||||||
|
|
||||||
|
# File/Note events
|
||||||
|
if "NodeCreatedEvent" in event_class or "NodeWrittenEvent" in event_class:
|
||||||
|
path = event_data["node"]["path"]
|
||||||
|
|
||||||
|
# Only process markdown files for notes
|
||||||
|
if not path.endswith(".md"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# IMPORTANT: Check if 'id' exists (missing in deletion events)
|
||||||
|
doc_id = str(event_data["node"].get("id", ""))
|
||||||
|
if not doc_id:
|
||||||
|
# For missing ID, use path-based identifier
|
||||||
|
doc_id = f"path:{path}"
|
||||||
|
|
||||||
|
return DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="note",
|
||||||
|
operation="index",
|
||||||
|
modified_at=payload["time"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# File deletion events
|
||||||
|
elif "NodeDeletedEvent" in event_class:
|
||||||
|
path = event_data["node"]["path"]
|
||||||
|
|
||||||
|
if not path.endswith(".md"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Deletion events DON'T have node.id - use path
|
||||||
|
return DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=f"path:{path}", # Path-based since ID unavailable
|
||||||
|
doc_type="note",
|
||||||
|
operation="delete",
|
||||||
|
modified_at=payload["time"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calendar creation/update events
|
||||||
|
elif "CalendarObjectCreatedEvent" in event_class or \
|
||||||
|
"CalendarObjectUpdatedEvent" in event_class:
|
||||||
|
return DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=str(event_data["objectData"]["id"]),
|
||||||
|
doc_type="calendar_event",
|
||||||
|
operation="index",
|
||||||
|
modified_at=event_data["objectData"]["lastmodified"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calendar deletion events
|
||||||
|
elif "CalendarObjectDeletedEvent" in event_class:
|
||||||
|
return DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=str(event_data["objectData"]["id"]),
|
||||||
|
doc_type="calendar_event",
|
||||||
|
operation="delete",
|
||||||
|
modified_at=payload["time"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return None # Unsupported event type
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deduplication Strategy
|
||||||
|
|
||||||
|
**Problem:** Creating a note triggers 3-5 webhooks
|
||||||
|
**Solution:** Idempotent processing + task deduplication
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In webhook handler
|
||||||
|
async def handle_nextcloud_webhook(request: Request) -> JSONResponse:
|
||||||
|
payload = await request.json()
|
||||||
|
|
||||||
|
task = extract_document_task(
|
||||||
|
payload["event"]["class"],
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if task:
|
||||||
|
# Idempotent: Queue will only process latest version
|
||||||
|
await document_queue.send(task)
|
||||||
|
|
||||||
|
return JSONResponse({"status": "received"}, status_code=200)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Path-Based Fallback for Deletions
|
||||||
|
|
||||||
|
Since deletion events lack `node.id`, use path-based identification:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In Qdrant delete logic
|
||||||
|
async def delete_document(user_id: str, doc_id: str, doc_type: str):
|
||||||
|
if doc_id.startswith("path:"):
|
||||||
|
# Path-based deletion
|
||||||
|
path = doc_id.removeprefix("path:")
|
||||||
|
# Search Qdrant for document with matching path in metadata
|
||||||
|
points = await qdrant.scroll(
|
||||||
|
collection_name=collection,
|
||||||
|
scroll_filter=Filter(must=[
|
||||||
|
FieldCondition(
|
||||||
|
key="user_id",
|
||||||
|
match=MatchValue(value=user_id),
|
||||||
|
),
|
||||||
|
FieldCondition(
|
||||||
|
key="metadata.path",
|
||||||
|
match=MatchValue(value=path),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
# Delete found points
|
||||||
|
else:
|
||||||
|
# ID-based deletion (normal case)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Webhook Registration Filters
|
||||||
|
|
||||||
|
To reduce webhook volume, add filters:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"uri": "http://mcp:8000/webhooks/nextcloud",
|
||||||
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||||
|
"eventFilter": {
|
||||||
|
"event.node.path": "/^.*\\.md$/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This filters to only `.md` files at the webhook registration level (not handler level).
|
||||||
|
|
||||||
|
### 5. Monitoring and Metrics
|
||||||
|
|
||||||
|
Add webhook-specific metrics:
|
||||||
|
|
||||||
|
```python
|
||||||
|
webhook_notifications_received_total{event_type="note_created"} 42
|
||||||
|
webhook_processing_duration_seconds{event_type="note_created"} 0.023
|
||||||
|
webhook_errors_total{error_type="parse_error"} 2
|
||||||
|
webhook_duplicates_filtered_total{doc_type="note"} 15
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist for Implementation
|
||||||
|
|
||||||
|
- [x] File creation webhook triggers document indexing
|
||||||
|
- [x] File update webhook triggers reindexing
|
||||||
|
- [x] File deletion webhook triggers document removal
|
||||||
|
- [ ] File deletion without ID successfully removes document (path-based)
|
||||||
|
- [x] Calendar creation webhook triggers event indexing
|
||||||
|
- [x] Calendar update webhook triggers event reindexing
|
||||||
|
- [ ] Calendar deletion webhook triggers event removal (NOT TESTED - webhook didn't fire)
|
||||||
|
- [ ] Duplicate webhooks are deduplicated
|
||||||
|
- [ ] Non-markdown file webhooks are ignored
|
||||||
|
- [ ] Malformed webhook payloads return 400 error
|
||||||
|
- [ ] Webhook authentication validates shared secret
|
||||||
|
- [ ] Webhook processing completes within 50ms
|
||||||
|
|
||||||
|
## Appendix: Raw Webhook Logs
|
||||||
|
|
||||||
|
Complete webhook logs with full payloads are available in MCP container logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs mcp | grep -A 30 "🔔 Webhook received"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Nextcloud webhooks work as documented with minor exceptions:
|
||||||
|
|
||||||
|
1. ✅ **File/Note Events:** Fully functional and match expected schemas
|
||||||
|
2. ✅ **Calendar Creation/Update:** Fully functional with rich metadata
|
||||||
|
3. ❌ **Calendar Deletion:** Webhook did not fire (requires investigation)
|
||||||
|
4. ⚠️ **Schema Discrepancy:** `node.id` is integer (not string as documented)
|
||||||
|
5. ⚠️ **Deletion Schema:** Missing `node.id` field (only `path` provided)
|
||||||
|
|
||||||
|
**Overall Status:** Ready for ADR-010 implementation with noted caveats. Calendar deletion webhook issue should be reported to Nextcloud and may require alternative approach (polling or trash bin events).
|
||||||
Reference in New Issue
Block a user