1707b2e6e1
Add NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE env vars to configure TLS certificate verification for all outbound Nextcloud connections. Centralizes SSL config via a new HTTP client factory (http.py) used by all 27 Nextcloud-bound call sites, including API clients, OIDC endpoints, OAuth flows, and health checks. Closes #560 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
544 lines
20 KiB
Python
544 lines
20 KiB
Python
"""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,
|
|
)
|
|
|
|
from ..http import nextcloud_httpx_client
|
|
|
|
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
|
|
assert username is not None and password is not None # Type narrowing
|
|
return nextcloud_httpx_client(
|
|
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 nextcloud_httpx_client(
|
|
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,
|
|
)
|