diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index b4b4b3c..f25b107 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -8,13 +8,12 @@ from typing import TYPE_CHECKING, Optional from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor if TYPE_CHECKING: - from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage + from nextcloud_mcp_server.auth.storage import RefreshTokenStorage import anyio import click import httpx -import uvicorn from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import Context, FastMCP @@ -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.observability import ( ObservabilityMiddleware, - get_uvicorn_logging_config, setup_metrics, setup_tracing, ) @@ -219,6 +217,7 @@ class AppContext: """Application context for BasicAuth mode.""" client: NextcloudClient + storage: Optional["RefreshTokenStorage"] = None document_send_stream: Optional[MemoryObjectSendStream] = None document_receive_stream: Optional[MemoryObjectReceiveStream] = None shutdown_event: Optional[anyio.Event] = None @@ -292,7 +291,7 @@ async def load_oauth_client_credentials( # Try loading from SQLite storage try: - from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage + from nextcloud_mcp_server.auth.storage import RefreshTokenStorage storage = RefreshTokenStorage.from_env() await storage.initialize() @@ -346,7 +345,7 @@ async def load_oauth_client_credentials( # Ensure OAuth client in SQLite storage 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() await storage.initialize() @@ -396,6 +395,13 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]: client = NextcloudClient.from_env() 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() @@ -450,6 +456,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]: try: yield AppContext( client=client, + storage=storage, document_send_stream=send_stream, document_receive_stream=receive_stream, shutdown_event=shutdown_event, @@ -466,7 +473,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]: else: # No vector sync - simple lifecycle try: - yield AppContext(client=client) + yield AppContext(client=client, storage=storage) finally: logger.info("Shutting down BasicAuth mode") await client.close() @@ -583,7 +590,7 @@ async def setup_oauth_config(): refresh_token_storage = None if enable_offline_access: try: - from nextcloud_mcp_server.auth.refresh_token_storage import ( + from nextcloud_mcp_server.auth.storage import ( RefreshTokenStorage, ) @@ -1041,6 +1048,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): logger.info( 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 == "/user": + 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) # For streamable-http transport, FastMCP lifespan isn't automatically triggered @@ -1388,6 +1412,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): user_info_html, user_info_json, ) + 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 # This prevents SessionAuthBackend from interfering with FastMCP's OAuth @@ -1397,6 +1426,16 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): Route( "/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint" ), # /user/revoke → revoke_session + # Webhook management routes (admin-only) + Route("/webhooks", webhook_management_pane, methods=["GET"]), # /user/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) @@ -1528,249 +1567,3 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): logger.info("WWW-Authenticate scope challenge handler enabled") 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() diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index f4e3797..3931f31 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -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__) diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index cf5fff8..35ae823 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -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__) diff --git a/nextcloud_mcp_server/auth/provisioning_decorator.py b/nextcloud_mcp_server/auth/provisioning_decorator.py index b639331..52ad57a 100644 --- a/nextcloud_mcp_server/auth/provisioning_decorator.py +++ b/nextcloud_mcp_server/auth/provisioning_decorator.py @@ -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__) diff --git a/nextcloud_mcp_server/auth/refresh_token_storage.py b/nextcloud_mcp_server/auth/storage.py similarity index 81% rename from nextcloud_mcp_server/auth/refresh_token_storage.py rename to nextcloud_mcp_server/auth/storage.py index 8d24682..ce3db76 100644 --- a/nextcloud_mcp_server/auth/refresh_token_storage.py +++ b/nextcloud_mcp_server/auth/storage.py @@ -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: """ diff --git a/nextcloud_mcp_server/auth/token_broker.py b/nextcloud_mcp_server/auth/token_broker.py index 152163c..a4d68aa 100644 --- a/nextcloud_mcp_server/auth/token_broker.py +++ b/nextcloud_mcp_server/auth/token_broker.py @@ -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__) diff --git a/nextcloud_mcp_server/auth/token_exchange.py b/nextcloud_mcp_server/auth/token_exchange.py index 2ded73e..4ccc800 100644 --- a/nextcloud_mcp_server/auth/token_exchange.py +++ b/nextcloud_mcp_server/auth/token_exchange.py @@ -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__) diff --git a/nextcloud_mcp_server/auth/webhook_routes.py b/nextcloud_mcp_server/auth/webhook_routes.py new file mode 100644 index 0000000..bec9ca1 --- /dev/null +++ b/nextcloud_mcp_server/auth/webhook_routes.py @@ -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=""" +
+ """ + ) + + # 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'✓ Enabled ({num_webhooks} webhooks)' + action_button = f""" + + """ + else: + status_badge = 'Not Enabled' + action_button = f""" + + """ + + preset_cards_html += f""" +{preset["description"]}
++ App: {preset["app"]} | + Events: {len(preset["events"])} +
+Enable webhook presets with one click for common synchronization scenarios.
+Showing {len(available_presets)} preset(s) for your installed apps ({len(installed_apps)} detected)
+ + {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""" +Error Loading Webhooks
+{str(e)}
+{preset["description"]}
++ App: {preset["app"]} | + Events: {len(preset["events"])} +
+{preset["description"]}
++ App: {preset["app"]} | + Events: {len(preset["events"])} +
+