refactor: consolidate database storage for webhooks and OAuth tokens
Refactored the storage system to use a unified SQLite database for both webhook tracking and OAuth token storage, available in both BasicAuth and OAuth modes. Changes: - Renamed refresh_token_storage.py → storage.py - Made TOKEN_ENCRYPTION_KEY optional (only required for OAuth token ops) - Added registered_webhooks table with schema versioning - Added webhook storage methods (store, get, delete, list, clear) - Initialize storage in both BasicAuth and OAuth modes - Updated webhook routes to persist registrations in database - Database-first pattern for webhook status checks (performance) - Updated all imports across codebase Storage Behavior: - Database created automatically at startup if needed - Existing databases detected and reused - Server fails fast if database initialization fails - No migrations needed (OAuth feature is experimental) Testing: - Added 13 comprehensive unit tests for webhook storage - All 118 unit tests pass - All 5 smoke tests pass - Verified fail-fast behavior on initialization errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import Any
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from mcp.server.fastmcp import Context
|
||||
from mcp.shared.exceptions import McpError
|
||||
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__)
|
||||
|
||||
|
||||
+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
|
||||
- Used ONLY by background jobs to obtain access tokens
|
||||
- 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
|
||||
- Queried ONCE at login, displayed from cache thereafter
|
||||
- NOT used for authorization decisions or background jobs
|
||||
|
||||
IMPORTANT: These are separate concerns. Browser sessions read profile cache for
|
||||
display purposes. Background jobs use refresh tokens for API access. Never mix
|
||||
the two.
|
||||
3. **Webhook Registration Tracking** (both modes, for webhook management)
|
||||
- Tracks registered webhook IDs mapped to presets
|
||||
- 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
|
||||
@@ -34,25 +39,34 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
- 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)
|
||||
This class manages multiple concerns across both BasicAuth and OAuth modes:
|
||||
|
||||
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:
|
||||
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.cipher = Fernet(encryption_key)
|
||||
self.cipher = Fernet(encryption_key) if encryption_key else None
|
||||
self._initialized = False
|
||||
|
||||
@classmethod
|
||||
@@ -62,41 +76,42 @@ class RefreshTokenStorage:
|
||||
|
||||
Environment variables:
|
||||
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:
|
||||
RefreshTokenStorage instance
|
||||
|
||||
Raises:
|
||||
ValueError: If TOKEN_ENCRYPTION_KEY is not set
|
||||
Note:
|
||||
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")
|
||||
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
|
||||
if not encryption_key_b64:
|
||||
raise ValueError(
|
||||
"TOKEN_ENCRYPTION_KEY environment variable is required. "
|
||||
"Generate one with: python -c 'from cryptography.fernet import Fernet; "
|
||||
"print(Fernet.generate_key().decode())'"
|
||||
encryption_key = None
|
||||
if encryption_key_b64:
|
||||
# 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
|
||||
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)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@@ -204,6 +219,38 @@ class RefreshTokenStorage:
|
||||
"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()
|
||||
|
||||
# Set restrictive permissions after creation
|
||||
@@ -1104,6 +1151,123 @@ class RefreshTokenStorage:
|
||||
|
||||
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:
|
||||
"""
|
||||
@@ -23,7 +23,7 @@ import httpx
|
||||
import jwt
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,7 +20,7 @@ import httpx
|
||||
import jwt
|
||||
|
||||
from ..config import get_settings
|
||||
from .refresh_token_storage import RefreshTokenStorage
|
||||
from .storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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 /user 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="/user/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="/user/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="/user/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="/user/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,
|
||||
)
|
||||
Reference in New Issue
Block a user